diff --git a/packages/cli/commands/memory.ts b/packages/cli/commands/memory.ts index 9a5f77aa..fd9a8b44 100644 --- a/packages/cli/commands/memory.ts +++ b/packages/cli/commands/memory.ts @@ -29,6 +29,7 @@ import { isAppErrorCode, requireMemoryAuth, requireSpace, + shellTildeExpansionHint, } from "../util.ts"; import { editMemory } from "./memory-edit.ts"; import { createMemoryImportCommand } from "./memory-import.ts"; @@ -440,7 +441,11 @@ function createMemorySearchCommand(): Command { console.log( `Found ${result.total} results (showing ${result.results.length})`, ); - if (result.results.length === 0) return; + if (result.results.length === 0) { + const hint = shellTildeExpansionHint(tree ?? undefined); + if (hint) clack.log.warn(hint); + return; + } console.log(); table( ["id", "content", "tree", "score"], @@ -610,7 +615,10 @@ function createMemoryDeltreeCommand(): Command { const preview = await client.memory.deleteTree({ tree, dryRun: true }); if (preview.count === 0) { output({ count: 0 }, fmt, () => { - clack.log.warn(`No memories found under '${tree}'`); + const hint = shellTildeExpansionHint(tree); + clack.log.warn( + `No memories found under '${tree}'${hint ? `\n${hint}` : ""}`, + ); }); return; } @@ -691,6 +699,10 @@ function createMemoryCountCommand(): Command { await output(result, fmt, () => { console.log(formatMemoryCount(result.count, maxCount)); + if (result.count === 0) { + const hint = shellTildeExpansionHint(tree); + if (hint) clack.log.warn(hint); + } }); } catch (error) { handleError(error, fmt); @@ -723,6 +735,10 @@ function createMemoryTreeCommand(): Command { output(result, fmt, () => { console.log(renderTree(result.nodes, filter)); + if (result.nodes.length === 0) { + const hint = shellTildeExpansionHint(filter); + if (hint) clack.log.warn(hint); + } }); } catch (error) { handleError(error, fmt); @@ -757,7 +773,10 @@ function createMemoryMoveCommand(): Command { if (preview.count === 0) { output({ count: 0 }, fmt, () => { - clack.log.warn(`No memories found under '${src}'`); + const hint = shellTildeExpansionHint(src); + clack.log.warn( + `No memories found under '${src}'${hint ? `\n${hint}` : ""}`, + ); }); return; } @@ -828,7 +847,10 @@ function createMemoryCopyCommand(): Command { if (preview.count === 0) { output({ count: 0 }, fmt, () => { - clack.log.warn(`No memories found under '${src}'`); + const hint = shellTildeExpansionHint(src); + clack.log.warn( + `No memories found under '${src}'${hint ? `\n${hint}` : ""}`, + ); }); return; } @@ -967,7 +989,10 @@ function createMemoryExportCommand(): Command { if (memories.length === 0) { output({ count: 0 }, fmt, () => { - clack.log.warn("No memories found matching filters."); + const hint = shellTildeExpansionHint(opts.tree); + clack.log.warn( + `No memories found matching filters.${hint ? `\n${hint}` : ""}`, + ); }); return; } diff --git a/packages/cli/util.test.ts b/packages/cli/util.test.ts index 10590669..a6e20b5c 100644 --- a/packages/cli/util.test.ts +++ b/packages/cli/util.test.ts @@ -8,7 +8,8 @@ import type { MemoryClient, UserClient } from "@memory.build/client"; // Dynamic import to avoid pulling in @clack/prompts at top level (it touches // process.stdin). -const { resolveSpacePrincipalId, resolveAgentId } = await import("./util.ts"); +const { resolveSpacePrincipalId, resolveAgentId, shellTildeExpansionHint } = + await import("./util.ts"); const UUID = "019d694f-79f6-7595-8faf-b70b01c11f98"; @@ -71,3 +72,87 @@ describe("resolveAgentId", () => { expect(user.agent.list).toHaveBeenCalled(); }); }); + +// ============================================================================= +// shellTildeExpansionHint +// ============================================================================= + +const HOME = "/Users/me"; +// argv is [exec, script, ...userArgs]; the helper reads user args from index 2. +const argv = (...userArgs: string[]) => ["bun", "me", ...userArgs]; + +describe("shellTildeExpansionHint", () => { + test("rebuilds the full command, quoting the shell-expanded ~ token", () => { + // `me export --tree ~/granola ~/Downloads/granola.bak` — the shell expands + // both `~`s; only the tree token is the mistake, so only it gets requoted. + expect( + shellTildeExpansionHint( + `${HOME}/granola`, + argv( + "export", + "--tree", + `${HOME}/granola`, + `${HOME}/Downloads/granola.bak`, + ), + HOME, + ), + ).toBe( + "Hint: your shell may have expanded '~'. Try: me export --tree '~/granola' /Users/me/Downloads/granola.bak", + ); + }); + + test("bare ~ (home itself) suggests '~'", () => { + expect(shellTildeExpansionHint(HOME, argv("count", HOME), HOME)).toBe( + "Hint: your shell may have expanded '~'. Try: me count '~'", + ); + }); + + test("quotes other args that need it (e.g. a query with spaces)", () => { + expect( + shellTildeExpansionHint( + `${HOME}/notes`, + argv("search", "foo bar", "--tree", `${HOME}/notes`), + HOME, + ), + ).toBe( + "Hint: your shell may have expanded '~'. Try: me search 'foo bar' --tree '~/notes'", + ); + }); + + test("returns null for a real tree filter", () => { + expect( + shellTildeExpansionHint( + "share/notes", + argv("search", "--tree", "share/notes"), + HOME, + ), + ).toBeNull(); + // An already-quoted `~/granola` reaches us literally — not a home path. + expect( + shellTildeExpansionHint( + "~/granola", + argv("search", "--tree", "~/granola"), + HOME, + ), + ).toBeNull(); + }); + + test("does not match a sibling that merely shares the home prefix", () => { + // `/Users/menagerie` is not under `/Users/me`. + expect( + shellTildeExpansionHint( + "/Users/menagerie", + argv("tree", "/Users/menagerie"), + HOME, + ), + ).toBeNull(); + }); + + test("returns null for empty input or a pathological '/' home", () => { + expect(shellTildeExpansionHint(undefined, argv("tree"), HOME)).toBeNull(); + expect(shellTildeExpansionHint("", argv("tree", ""), HOME)).toBeNull(); + expect( + shellTildeExpansionHint("/anything", argv("tree", "/anything"), "/"), + ).toBeNull(); + }); +}); diff --git a/packages/cli/util.ts b/packages/cli/util.ts index e658965d..5e2127e7 100644 --- a/packages/cli/util.ts +++ b/packages/cli/util.ts @@ -7,6 +7,7 @@ * - Principal / agent resolution * - Error handling */ +import { homedir } from "node:os"; import * as clack from "@clack/prompts"; import type { MemoryClient, UserClient } from "./client.ts"; import { createMemoryClient, createUserClient, RpcError } from "./client.ts"; @@ -194,6 +195,50 @@ export function isAppErrorCode(error: unknown, code: string): boolean { return error.appCode === code || (error.code as unknown) === code; } +/** POSIX-quote a single argv token unless it is already a shell-safe bareword. */ +function shellQuoteArg(token: string): string { + if (/^[A-Za-z0-9_,.:/=@%+-]+$/.test(token)) return token; + return `'${token.replace(/'/g, `'\\''`)}'`; +} + +/** + * Diagnose a tree filter/path that the shell mangled before it reached us. + * + * The user-facing home shortcut is a leading `~` (`~/notes` → the caller's + * memory home). But an *unquoted* `~` is expanded by the shell, not us: zsh/bash + * turn `~/notes` into `$HOME/notes` and `~notes` into a lookup of user `notes`'s + * home. So `me search --tree ~/notes` arrives here as `--tree /Users/me/notes`, + * which normalizes to the ltree filter `Users.me.notes` and silently matches + * nothing — exactly when the caller meant their home and should have quoted it. + * + * Given a tree value *as it arrived in argv*, return a one-line hint nudging the + * caller to quote `~`, but only when the value is the caller's filesystem home + * or a child of it (the tell-tale of `~` expansion). A real memory tree path is + * never an absolute filesystem path, so this never fires on a legitimate filter. + * Returns null otherwise. Call it only on a zero-result path: a non-null return + * then means "no matches AND the filter looks shell-expanded". + * + * The suggestion is the *full command* as typed, with the shell-expanded home + * token swapped for the quoted `~` form (and any other arg quoted as needed) so + * it's copy-pasteable. `argv` (post-expansion, defaulting to `process.argv` — + * user args at index 2, matching the rest of this CLI) and `home` are injectable + * for testing. + */ +export function shellTildeExpansionHint( + rawTree: string | undefined, + argv: readonly string[] = process.argv, + home: string = homedir(), +): string | null { + if (!rawTree || !home || home === "/") return null; + if (rawTree !== home && !rawTree.startsWith(`${home}/`)) return null; + const suggestion = `~${rawTree.slice(home.length)}`; // "~" or "~/notes" + const command = argv + .slice(2) + .map((arg) => (arg === rawTree ? `'${suggestion}'` : shellQuoteArg(arg))) + .join(" "); + return `Hint: your shell may have expanded '~'. Try: me ${command}`; +} + /** * Detect an authentication error from the server (HTTP 401 / `UNAUTHORIZED`). */