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
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ getmnemo search "what brand color does Acme use?" --container org:acme
getmnemo doctor # verify auth + API reachability
```

> `add` and `search` require a **container** (the tenant boundary, e.g.
> `user:jane` or `org:acme`). Pass `--container <tag>`, set `GETMNEMO_CONTAINER`,
> or add `defaultContainerTag` to `~/.getmnemo/config.json`.
> Every memory command (`add`, `search`, `get`, `rm`, `list`) requires a
> **container** (the tenant boundary, e.g. `user:jane` or `org:acme`). Pass
> `--container <tag>`, set `GETMNEMO_CONTAINER`, or add `defaultContainerTag`
> to `~/.getmnemo/config.json`.

## Commands

Expand All @@ -47,11 +48,11 @@ getmnemo doctor # verify auth + API reachability
| --- | --- |
| `getmnemo add "<content>" --container <tag> [-m key=value ...]` | Add a memory to a container with optional metadata. |
| `getmnemo search "<query>" --container <tag> [--limit 5]` | Semantic search within a container. |
| `getmnemo get <id>` | Fetch a single memory. |
| `getmnemo rm <id> [--yes]` | Delete a memory. |
| `getmnemo list [--container <tag>] [--limit 20] [--cursor <c>]` | Paginate the workspace (optionally filtered by container). |
| `getmnemo get <id> --container <tag>` | Fetch a single memory. |
| `getmnemo rm <id> --container <tag> [--yes] [--permanent]` | Delete a memory (recoverable by default; `--permanent` purges immediately). |
| `getmnemo list --container <tag> [--limit 20] [--cursor <c>]` | Paginate the memories in a container. |

`--container` / `-C` accepts a container tag (e.g. `user:jane`). It is required on `add`/`search` and an optional filter on `list`. Resolution order: `--container` flag → `GETMNEMO_CONTAINER` env → `defaultContainerTag` in config.
`--container` / `-C` accepts a container tag (e.g. `user:jane`) and is required on every memory command. Resolution order: `--container` flag → `GETMNEMO_CONTAINER` env → `defaultContainerTag` in config.

### Workspaces

Expand Down Expand Up @@ -88,7 +89,7 @@ getmnemo doctor # verify auth + API reachability
| `GETMNEMO_API_KEY` | Overrides the saved API key. | — |
| `GETMNEMO_WORKSPACE_ID` | Overrides the active workspace. | — |
| `GETMNEMO_API_URL` | Overrides the API base URL. | `https://api.mnemohq.com` |
| `GETMNEMO_CONTAINER` | Default container tag for `add`/`search`/`list` when no `--container` flag is given. | — |
| `GETMNEMO_CONTAINER` | Default container tag for the memory commands when no `--container` flag is given. | — |

Environment variables take precedence over `~/.getmnemo/config.json`.

Expand All @@ -102,7 +103,7 @@ getmnemo add "Customer asked about SOC 2 timeline" --container org:acme -m chann
getmnemo search "soc 2" --container org:acme --limit 3 --json | jq '.results[].memoryId'

# delete without prompting (CI-safe)
getmnemo rm mem_01HX... --yes
getmnemo rm mem_01HX... --container org:acme --yes

# generate Claude Desktop MCP config
getmnemo mcp --client claude > claude_desktop_config.json
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"dependencies": {
"commander": "^12.1.0",
"getmnemo": "^0.2.0",
"getmnemo": "^0.5.1",
"kleur": "^4.1.5",
"prompts": "^2.4.2"
},
Expand Down
17 changes: 11 additions & 6 deletions scripts/prod-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,18 +183,23 @@ async function main() {
console.log('[smoke] container B:', containerB)

// Track created ids so cleanup runs even if assertions throw.
const createdIds = []
// Track each created id WITH its container: `rm` requires a --container
// (by-id routes are scoped), and the two writes land in different ones.
const created = []

try {
// ---- HAPPY PATH: add to two distinct containers --------------------
const addA = await runCli(['add', alphaContent, '--container', containerA], cliEnv)
const addB = await runCli(['add', bravoContent, '--container', containerB], cliEnv)

createdIds.push(...addedIds(addA), ...addedIds(addB))
created.push(
...addedIds(addA).map((id) => ({ id, container: containerA })),
...addedIds(addB).map((id) => ({ id, container: containerB })),
)

if (createdIds.length < 2) {
if (created.length < 2) {
fail(
`add did not return ids for both writes — got ${createdIds.length} ` +
`add did not return ids for both writes — got ${created.length} ` +
`(addA.items=${addA?.items?.length ?? 0}, addB.items=${addB?.items?.length ?? 0})`,
)
}
Expand Down Expand Up @@ -235,9 +240,9 @@ async function main() {
console.log('[smoke] OK isolation: A↛B and B↛A — no cross-container leakage')
} finally {
// ---- CLEANUP: best-effort delete; failure warns, never fatal -------
for (const id of createdIds) {
for (const { id, container } of created) {
try {
await runCli(['rm', id, '--yes'], cliEnv)
await runCli(['rm', id, '--yes', '--container', container], cliEnv)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
console.warn(`[smoke] WARN: cleanup delete failed for memory ${id}: ${msg}`)
Expand Down
253 changes: 253 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { tmpdir } from "node:os";
import { join } from "node:path";
import prompts from "prompts";
import { buildCli } from "./cli.js";

describe("Mnemo CLI", () => {
Expand Down Expand Up @@ -86,4 +89,254 @@ describe("Mnemo CLI", () => {
expect(caught).toBeDefined();
expect((caught as { code?: string }).code).toBe("commander.unknownCommand");
});

describe("container scope on by-id memory commands", () => {
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}

function firstCall(fetchMock: ReturnType<typeof vi.fn>): [string, RequestInit] {
const call = fetchMock.mock.calls[0];
if (!call) throw new Error("fetch was never called");
return [String(call[0]), call[1] as RequestInit];
}

beforeEach(() => {
// Point HOME at a nonexistent dir so a real ~/.getmnemo/config.json
// (e.g. a developer's defaultContainerTag) can't leak into assertions.
vi.stubEnv("HOME", join(tmpdir(), "getmnemo-cli-test-home-nonexistent"));
vi.stubEnv("GETMNEMO_API_KEY", "mk_test_key");
vi.stubEnv("GETMNEMO_WORKSPACE_ID", "ws_test");
vi.stubEnv("GETMNEMO_API_URL", "https://api.test.invalid");
vi.stubEnv("GETMNEMO_CONTAINER", undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it("get sends the --container tag as a containerTag query param", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" }));
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync([
"node", "getmnemo", "--json", "get", "mem_1", "--container", "user:jane",
]);

const [url] = firstCall(fetchMock);
expect(url).toBe(
"https://api.test.invalid/v1/memories/mem_1?containerTag=user%3Ajane",
);
expect(stdout).toMatch(/mem_1/);
});

it("get --container flag wins over GETMNEMO_CONTAINER", async () => {
vi.stubEnv("GETMNEMO_CONTAINER", "env:fallback");
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" }));
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync([
"node", "getmnemo", "--json", "get", "mem_1", "--container", "user:jane",
]);

const [url] = firstCall(fetchMock);
expect(url).toContain("containerTag=user%3Ajane");
expect(url).not.toContain("env%3Afallback");
});

it("get falls back to GETMNEMO_CONTAINER when no flag is given", async () => {
vi.stubEnv("GETMNEMO_CONTAINER", "user:env");
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" }));
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync(["node", "getmnemo", "--json", "get", "mem_1"]);

const [url] = firstCall(fetchMock);
expect(url).toContain("containerTag=user%3Aenv");
});

it("get exits 2 with the container-required error when nothing resolves", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("__exit__");
}) as never);

const program = buildCli();
program.exitOverride();
await expect(
program.parseAsync(["node", "getmnemo", "get", "mem_1"]),
).rejects.toThrow("__exit__");

expect(exitSpy).toHaveBeenCalledWith(2);
expect(stderr).toMatch(/A container is required/);
expect(fetchMock).not.toHaveBeenCalled();
});

it("get --json emits a machine-readable container_required error", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("__exit__");
}) as never);

const program = buildCli();
program.exitOverride();
await expect(
program.parseAsync(["node", "getmnemo", "--json", "get", "mem_1"]),
).rejects.toThrow("__exit__");

expect(exitSpy).toHaveBeenCalledWith(2);
expect(stdout).toMatch(/"error": "container_required"/);
expect(fetchMock).not.toHaveBeenCalled();
});

it("rm --yes sends the container tag on the DELETE and reports the recovery window", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: "mem_9",
deleted: true,
receipt: {
id: "mem_9",
eventId: "evt_1",
status: "restorable",
completedAt: "2026-08-19T00:00:00Z",
purged: {},
recoveryId: "rec_1",
restorableUntil: "2026-09-01T00:00:00Z",
},
}),
);
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync([
"node", "getmnemo", "rm", "mem_9", "--yes", "--container", "user:jane",
]);

const [url, init] = firstCall(fetchMock);
expect(init.method).toBe("DELETE");
expect(url).toBe(
"https://api.test.invalid/v1/memories/mem_9?containerTag=user%3Ajane",
);
expect(stdout).toMatch(/Deleted mem_9 \(restorable until 2026-09-01T00:00:00Z\)/);
});

it("rm --permanent sends permanent=true alongside the container tag", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "mem_9", deleted: true }));
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync([
"node", "getmnemo", "rm", "mem_9", "--yes", "--permanent", "--container", "user:jane",
]);

const [url] = firstCall(fetchMock);
expect(url).toBe(
"https://api.test.invalid/v1/memories/mem_9?permanent=true&containerTag=user%3Ajane",
);
expect(stdout).toMatch(/Deleted mem_9/);
});

it("rm exits 2 without a container before sending any request", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("__exit__");
}) as never);

const program = buildCli();
program.exitOverride();
await expect(
program.parseAsync(["node", "getmnemo", "rm", "mem_9", "--yes"]),
).rejects.toThrow("__exit__");

expect(exitSpy).toHaveBeenCalledWith(2);
expect(stderr).toMatch(/A container is required/);
expect(fetchMock).not.toHaveBeenCalled();
});

it("list sends the resolved container tag as a query param", async () => {
vi.stubEnv("GETMNEMO_CONTAINER", "user:env");
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ items: [], nextCursor: null }));
vi.stubGlobal("fetch", fetchMock);

const program = buildCli();
program.exitOverride();
await program.parseAsync(["node", "getmnemo", "--json", "list"]);

const [url] = firstCall(fetchMock);
expect(url).toBe(
"https://api.test.invalid/v1/memories?limit=20&containerTag=user%3Aenv",
);
});

it("list exits 2 with the CLI's container-required message when nothing resolves", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("__exit__");
}) as never);

const program = buildCli();
program.exitOverride();
await expect(
program.parseAsync(["node", "getmnemo", "list"]),
).rejects.toThrow("__exit__");

expect(exitSpy).toHaveBeenCalledWith(2);
expect(stderr).toMatch(/A container is required/);
expect(fetchMock).not.toHaveBeenCalled();
});

it("login verifies credentials with a synthetic probe container", async () => {
// Own HOME so the config write cannot leak into the shared stub path.
vi.stubEnv(
"HOME",
join(tmpdir(), `getmnemo-cli-test-home-login-${process.pid}`),
);
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ items: [], nextCursor: null }));
vi.stubGlobal("fetch", fetchMock);
// login prompts for the API URL (the key/workspace prompts are skipped
// by the flags); inject the answer so the test never blocks on stdin.
prompts.inject(["https://api.test.invalid"]);

const program = buildCli();
program.exitOverride();
await program.parseAsync([
"node", "getmnemo", "--json", "login",
"--api-key", "mk_login_test", "--workspace-id", "ws_login",
]);

const [url] = firstCall(fetchMock);
expect(url).toBe(
"https://api.test.invalid/v1/memories?limit=1&containerTag=cli%3Alogin-probe",
);
expect(stdout).toMatch(/"ok": true/);
});
});
});
6 changes: 5 additions & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export function registerAuthCommands(program: Command): void {
// not silently overwrite a previously-working config.
try {
const probe = new Mnemo({ apiKey, workspaceId, baseUrl });
await probe.list({ limit: 1 });
// getmnemo 0.5.1 list() throws client-side without a container, and
// none is configured yet at login. Probe with a synthetic read-only
// tag: we only care whether the key authenticates (401 vs 2xx), and
// listing an empty container is a valid, cheap request.
await probe.list({ limit: 1, containerTag: "cli:login-probe" });
} catch (err: unknown) {
const status = (err as { status?: number })?.status;
const message =
Expand Down
Loading