Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions packages/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
isAppErrorCode,
requireMemoryAuth,
requireSpace,
shellTildeExpansionHint,
} from "../util.ts";
import { editMemory } from "./memory-edit.ts";
import { createMemoryImportCommand } from "./memory-import.ts";
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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}` : ""}`,
);
Comment on lines +619 to +621

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer to DRY these up.

clack.log.warn(
  `No memories found under '${tree}'${hint ? `\n${hint}` : ''}`
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9793b1c — collapsed to the inline template you suggested (applied across export/deltree/move/copy). Thanks!

});
return;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
87 changes: 86 additions & 1 deletion packages/cli/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
});
45 changes: 45 additions & 0 deletions packages/cli/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`).
*/
Expand Down