diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e02d859 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to `getmnemo-cli` are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[SemVer](https://semver.org/). + +## [0.3.0] — 2026-09-02 + +### Added +- `getmnemo brief [--container ] [--date] [--timezone] [--days] [--sections]` — your Daily Brief (overdue / due today / coming up, important dates, today's meetings, follow-ups, last-24h captures). `GET /v1/brief`, scope `brief:read`. +- `getmnemo timeline --container [--from --to --types --direction --limit --cursor]` — merged chronological stream for one container. `GET /v1/timeline`, scope `timeline:read`. +- `getmnemo people list|get |add ` — one memory container per person, with `--relationship --email --phone --company --notes --alias --important-date`. `/v1/people`, scopes `people:read` / `people:write`. +- `getmnemo reminders list|upcoming|add --due |complete ` — reminders are memories with a due date; `add` targets `--person ` or a container. `/v1/reminders`, scopes `reminders:read` / `reminders:write`. +- `getmnemo meetings upcoming|brief ` — calendar meetings with attendee ↔ person matching and a pre-meeting brief. `/v1/meetings`, scope `meetings:read` (+ `answer:read` for briefs). +- `getmnemo memories merge [--into ] [--content] [--type] [--merge-key] [-y]` — fold 2–20 memories into one; sources are soft-deleted and restorable. `POST /v1/memories/merge`, scopes `memories:write` + `memories:delete`. +- `--json` error envelope now carries the API's stable `code` (e.g. `FEATURE_DISABLED`, `PERSON_NOT_FOUND`) and `status`. + +### Changed +- `--version` is read from `package.json` at runtime (the hardcoded const had drifted to 0.2.0 while the package was 0.2.1). +- All new commands validate arguments locally (bounded integers, ISO-8601 dates, enums, csv subsets) and exit `2` before any request is sent. + +### Fixed +- `getmnemo mcp` now emits `npx -y getmnemo-mcp` (the published package) instead of the never-published `@mnemo/mcp`. + +### Notes +- The new commands need the matching scopes on your API key — existing keys gain nothing by default; mint or edit a key in the dashboard. +- These features are dark-launched server-side; a `503 FEATURE_DISABLED` means the deployment has not enabled that feature yet. +- The new surfaces use a CLI-owned transport (`src/lib/personal-api.ts`) with wire shapes identical to `getmnemo@0.6.0`'s resources; the dependency stays `^0.5.1` until 0.6.0 is published. + +## [0.2.1] — 2026-08-19 + +### Fixed +- Require a container scope on by-id memory routes (`get`, `rm`); bump `getmnemo` to `^0.5.1`. + +## [0.2.0] + +### Changed +- API-contract reconciliation (`q` search field, `results` hits, `items` receipts) + CI prod smoke gate before publish. diff --git a/README.md b/README.md index 41a48b5..2b3d4f8 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ getmnemo doctor # verify auth + API reachability `--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. +### Daily brief, timeline, people, reminders, meetings + +These commands need the matching scopes on your API key (`brief:read`, `timeline:read`, `people:read|write`, `reminders:read|write`, `meetings:read`, and `memories:write`+`memories:delete` for merge). A `503 FEATURE_DISABLED` means the deployment has not enabled that feature yet. + +| Command | Description | +| --- | --- | +| `getmnemo brief [--container ] [--date YYYY-MM-DD] [--timezone ] [--days 7] [--sections core,followUps,meetings]` | Your Daily Brief: overdue / due today / coming up, important dates, today's meetings, follow-ups, last-24h captures. Container resolves like the memory commands; timezone defaults to your system zone. | +| `getmnemo timeline --container [--from ] [--to ] [--types memory,reminder,document,event] [--direction desc\|asc] [--limit 50] [--cursor ]` | Merged chronological stream of memories, reminders and documents for one container (`event` is opt-in). | +| `getmnemo people list [-q ] [--include-archived] [--limit 50] [--cursor ]` | List people (one memory container per person, `person:`). | +| `getmnemo people get ` | Show a person with contact fields, important dates and reminder counts. | +| `getmnemo people add "" [--slug] [--relationship] [--email] [--phone] [--company] [--notes] [--alias ...] [--important-date label=YYYY-MM-DD[:recurring]...]` | Add a person. A slug collision is a `409 PERSON_EXISTS`; retry with `--slug`. | +| `getmnemo reminders list [--status open\|completed\|all] [--days ] [--due-after ] [--due-before ] [-C ] [--container-type person] [--limit 50] [--cursor ]` | Workspace-wide reminders ordered by due date. `-C` here is an explicit filter only (env/config defaults are not applied). | +| `getmnemo reminders upcoming [--days 7] [--timezone ] [-C ] [--container-type ] [--limit 50]` | Overdue / due today / coming up buckets in your timezone, plus important dates in the window. | +| `getmnemo reminders add "" --due (--person \| --container ) [--idempotency-key ] [-m key=value ...]` | Create a reminder for a person (lands in `person:`) or a container (flag → `GETMNEMO_CONTAINER` → config). | +| `getmnemo reminders complete ` | Mark a reminder done (clears `dueAt`, keeps the memory). | +| `getmnemo meetings upcoming [--days 7] [--limit 50] [--cursor ] [-C ]` | Upcoming meetings from connected calendars, attendees matched to people. | +| `getmnemo meetings brief [-q ""]` | Pre-meeting brief: reader summary, each attendee's open reminders + recent memories, previous meetings. | +| `getmnemo memories merge [...] [--into ] [--content ""] [--type ] [--merge-key ] [-m key=value ...] [-C ] [-y]` | Merge 2–20 memories from one container. With `--into` that memory survives; otherwise `--content` is required and a new memory is created. Sources are soft-deleted (restorable). Prompts unless `-y`; refuses without `-y` in non-interactive shells. | + ### Workspaces | Command | Description | @@ -65,7 +84,7 @@ getmnemo doctor # verify auth + API reachability | Command | Description | | --- | --- | -| `getmnemo mcp [--client claude\|cursor]` | Print an MCP server config snippet. | +| `getmnemo mcp [--client claude\|cursor]` | Print an MCP server config snippet (`npx -y getmnemo-mcp`). | | `getmnemo doctor` | Check auth + `GET /health`. | ## Global flags @@ -80,7 +99,9 @@ getmnemo doctor # verify auth + API reachability | --- | --- | | `0` | Success | | `1` | Runtime error (auth missing, API failure, not found, ...) | -| `2` | Invalid arguments / unknown command | +| `2` | Invalid arguments / unknown command / missing container / confirmation required | + +With `--json`, runtime errors are written to stderr as `{"ok": false, "error": "", "code": "", "status": }` — `code` is the API's stable error code (`FEATURE_DISABLED`, `PERSON_NOT_FOUND`, `MERGE_CROSS_CONTAINER`, ...), and argument errors as `{"ok": false, "error": "invalid_argument" | "container_required" | "confirmation_required", "message": "..."}`. ## Environment variables @@ -107,6 +128,24 @@ getmnemo rm mem_01HX... --container org:acme --yes # generate Claude Desktop MCP config getmnemo mcp --client claude > claude_desktop_config.json + +# morning routine +getmnemo brief --container user:me --timezone Asia/Karachi +getmnemo reminders upcoming --days 3 + +# people + reminders +getmnemo people add "Jane Doe" --relationship client --email jane@example.com --important-date birthday=1990-05-04:recurring +getmnemo reminders add "Send Jane the proposal" --due 2026-09-05T09:00:00Z --person jane-doe +getmnemo reminders complete + +# what happened in a container, newest first +getmnemo timeline --container person:jane-doe --types memory,reminder --limit 20 + +# prep for the next meeting +getmnemo meetings upcoming --days 1 --json | jq -r '.items[0].documentId' | xargs getmnemo meetings brief + +# fold duplicate memories into one +getmnemo memories merge --into --container user:me -y ``` ## Development diff --git a/package-lock.json b/package-lock.json index 590ad61..1564536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "getmnemo-cli", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "getmnemo-cli", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index e1a4576..aa502c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "getmnemo-cli", - "version": "0.2.1", + "version": "0.3.0", "description": "Official CLI for Mnemo — manage memories, workspaces, and integrations from your terminal.", "type": "module", "bin": { diff --git a/src/cli.test.ts b/src/cli.test.ts index 9f1ed7a..4cff0fe 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -51,6 +51,52 @@ describe("Mnemo CLI", () => { expect(stdout).toMatch(/workspace/); expect(stdout).toMatch(/mcp/); expect(stdout).toMatch(/doctor/); + expect(stdout).toMatch(/brief/); + expect(stdout).toMatch(/timeline/); + expect(stdout).toMatch(/people/); + expect(stdout).toMatch(/reminders/); + expect(stdout).toMatch(/meetings/); + expect(stdout).toMatch(/memories/); + }); + + it("--version prints the package.json version", async () => { + const program = buildCli(); + program.exitOverride(); + try { + await program.parseAsync(["node", "getmnemo", "--version"]); + } catch { + // commander throws on version by design + } + expect(stdout.trim()).toBe("0.3.0"); + }); + + it("exits 2 for an unknown option on a subcommand (exit callback is inherited)", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); + const program = buildCli(); + await expect(program.parseAsync(["node", "getmnemo", "people", "list", "--bogus"])).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderr).toMatch(/unknown option '--bogus'/); + }); + + it("lists subcommands in group help", async () => { + for (const [group, subs] of [ + ["people", ["list", "get", "add"]], + ["reminders", ["list", "upcoming", "add", "complete"]], + ["meetings", ["upcoming", "brief"]], + ["memories", ["merge"]], + ] as const) { + stdout = ""; + const program = buildCli(); + program.exitOverride(); + try { + await program.parseAsync(["node", "getmnemo", group, "--help"]); + } catch { + // commander throws on help by design + } + for (const sub of subs) expect(stdout).toMatch(new RegExp(`^\\s+${sub}\\b`, "m")); + } }); it("doctor reports failure when API is unreachable (mocked)", async () => { diff --git a/src/cli.ts b/src/cli.ts index 28bdaa0..75ba23d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,8 +7,14 @@ import { registerMemoryCommands } from "./commands/memory.js"; import { registerWorkspaceCommands } from "./commands/workspace.js"; import { registerMcpCommand } from "./commands/mcp.js"; import { registerDoctorCommand } from "./commands/doctor.js"; - -const VERSION = "0.2.0"; +import { registerBriefCommand } from "./commands/brief.js"; +import { registerTimelineCommand } from "./commands/timeline.js"; +import { registerPeopleCommands } from "./commands/people.js"; +import { registerRemindersCommands } from "./commands/reminders.js"; +import { registerMeetingsCommands } from "./commands/meetings.js"; +import { registerMemoriesCommands } from "./commands/memories.js"; +import { PersonalApiError } from "./lib/personal-api.js"; +import { CLI_VERSION } from "./lib/version.js"; // Color detection: kleur's autodetect can produce ANSI escapes when this CLI // is spawned as a subprocess (e.g. by `claude`, CI runners, or scripts that @@ -39,32 +45,54 @@ function configureColor(): void { } configureColor(); +/** Commander error codes that mean "bad invocation" → exit 2. */ +const USAGE_ERROR_CODES = new Set([ + "commander.unknownCommand", + "commander.unknownOption", + "commander.missingArgument", + "commander.missingMandatoryOptionValue", + "commander.optionMissingArgument", + "commander.excessArguments", + "commander.invalidArgument", + "commander.conflictingOption", +]); + export function buildCli(): Command { const program = new Command(); program .name("getmnemo") .description(kleur.cyan("Mnemo CLI") + " — manage memories from your terminal.") - .version(VERSION, "-v, --version", "print the CLI version") + .version(CLI_VERSION, "-v, --version", "print the CLI version") .option("--json", "format output as JSON for machine consumption", false) .showHelpAfterError("(add --help for additional information)"); - registerAuthCommands(program); - registerMemoryCommands(program); - registerWorkspaceCommands(program); - registerMcpCommand(program); - registerDoctorCommand(program); - + // Installed BEFORE the subcommands are registered: commander copies the + // exit callback into each `.command()` at creation time, so a late + // override only covers the root and `getmnemo people list --bogus` would + // exit 1 instead of the documented 2. program.exitOverride((err) => { - if (err.code === "commander.helpDisplayed" || err.code === "commander.version") { + if (err.code === "commander.helpDisplayed" || err.code === "commander.version" || err.code === "commander.help") { process.exit(0); } - if (err.code === "commander.unknownCommand" || err.code === "commander.unknownOption") { + if (USAGE_ERROR_CODES.has(err.code)) { process.exit(2); } process.exit(err.exitCode ?? 1); }); + registerAuthCommands(program); + registerMemoryCommands(program); + registerWorkspaceCommands(program); + registerMcpCommand(program); + registerDoctorCommand(program); + registerBriefCommand(program); + registerTimelineCommand(program); + registerPeopleCommands(program); + registerRemindersCommands(program); + registerMeetingsCommands(program); + registerMemoriesCommands(program); + return program; } @@ -82,7 +110,13 @@ async function main(): Promise { } catch (err) { const message = err instanceof Error ? err.message : String(err); if (wantsJson) { - process.stderr.write(JSON.stringify({ ok: false, error: message }) + "\n"); + // API errors carry the stable `code` from the API envelope (e.g. + // FEATURE_DISABLED, PERSON_NOT_FOUND) so scripts can branch on it. + const detail = + err instanceof PersonalApiError + ? { ok: false, error: message, code: err.code, status: err.status } + : { ok: false, error: message }; + process.stderr.write(JSON.stringify(detail) + "\n"); } else { process.stderr.write(kleur.red(`error: ${message}\n`)); } diff --git a/src/commands/brief.test.ts b/src/commands/brief.test.ts new file mode 100644 index 0000000..2c25ee2 --- /dev/null +++ b/src/commands/brief.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; + +const REMINDER = { + id: "r1", content: "Send Jane the proposal", memoryType: "reminder", metadata: null, + createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", + dueAt: "2026-09-03T09:00:00.000Z", createdBy: null, completedAt: null, + person: { slug: "jane-doe", displayName: "Jane Doe" }, +}; + +const BRIEF = { + date: "2026-09-03", + timezone: "Asia/Karachi", + generatedAt: "2026-09-03T01:00:00.000Z", + scope: { kind: "container", containerTag: "user:me" }, + reminders: { overdue: [], dueToday: [REMINDER], upcoming: [] }, + importantDates: [], + recentMemories: [ + { id: "m1", content: "Met Bob about the Q4 plan", memoryType: "note", metadata: null, createdAt: "2026-09-02T20:00:00.000Z", updatedAt: "2026-09-02T20:00:00.000Z", dueAt: null, createdBy: { kind: "api_key", id: "k", label: "Chrome extension" } }, + ], + counts: { memoriesLast24h: 1, documentsLast24h: 0 }, + followUps: { answer: "You promised Bob a draft by Friday.", citations: [], abstained: false, cached: true }, + meetings: [ + { documentId: "d1", eventId: "e1", title: "Sync with Jane", start: "2026-09-03T10:00:00+05:00", end: null, isAllDay: false, status: "confirmed", htmlLink: null, location: null, organizer: null, attendees: [{ email: "jane@example.com", name: "Jane", responseStatus: null, self: false, person: { slug: "jane-doe", displayName: "Jane Doe" } }], containerTag: "calendar:x", connectionId: "c", attendeeSource: "metadata" }, + ], +}; + +describe("getmnemo brief", () => { + const out = installHarness(); + + it("GETs /v1/brief with container, date, timezone, days and sections", async () => { + const fetchMock = mockFetch(jsonResponse(BRIEF)); + await run(["--json", "brief", "--container", "user:me", "--date", "2026-09-03", "--timezone", "Asia/Karachi", "--days", "3", "--sections", "core,meetings,core"]); + const req = requestAt(fetchMock); + expect(req.method).toBe("GET"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/brief?containerTag=user%3Ame&date=2026-09-03&timezone=Asia%2FKarachi&days=3§ions=core%2Cmeetings`); + expect(JSON.parse(out.stdout())).toMatchObject({ date: "2026-09-03" }); + }); + + it("resolves the container from GETMNEMO_CONTAINER and defaults timezone/days", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = mockFetch(jsonResponse(BRIEF)); + await run(["brief"]); + const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/brief?containerTag=user%3Aenv&timezone=${tz}&days=7`); + const text = out.stdout(); + expect(text).toMatch(/Daily brief — 2026-09-03/); + expect(text).toMatch(/Due today/); + expect(text).toMatch(/Send Jane the proposal/); + expect(text).toMatch(/Today's meetings/); + expect(text).toMatch(/Sync with Jane/); + expect(text).toMatch(/Follow-ups & promises/); + expect(text).toMatch(/You promised Bob a draft by Friday/); + expect(text).toMatch(/Captured in the last 24h/); + expect(text).toMatch(/Met Bob about the Q4 plan/); + expect(text).toMatch(/Chrome extension/); + expect(text).toMatch(/1 memories, 0 documents in the last 24h/); + expect(text).not.toMatch(/Overdue/); + }); + + it("exits 2 without a container before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["--json", "brief"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/"error": "container_required"/); + }); + + it("rejects a bad --date and unknown --sections before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["brief", "--container", "user:me", "--date", "03/09/2026"], 2); + await runExpectingExit(["brief", "--container", "user:me", "--sections", "core,weather"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--date must be YYYY-MM-DD/); + expect(out.stderr()).toMatch(/--sections must be a comma-separated subset/); + }); + + it("renders the abstained follow-ups copy and the empty-state fallback for a fully empty brief", async () => { + mockFetch(jsonResponse({ ...BRIEF, reminders: { overdue: [], dueToday: [], upcoming: [] }, importantDates: [], recentMemories: [], meetings: null, counts: null, followUps: { answer: "", citations: [], abstained: true, cached: false } })); + await run(["brief", "--container", "user:me"]); + expect(out.stdout()).toMatch(/Nothing outstanding that I can find/); + expect(out.stderr() + out.stdout()).toMatch(/Nothing to report today/); + }); + + it("renders the empty-state fallback when every section is null", async () => { + mockFetch(jsonResponse({ ...BRIEF, reminders: null, importantDates: null, recentMemories: null, meetings: null, counts: null, followUps: null })); + await run(["brief", "--container", "user:me"]); + expect(out.stderr() + out.stdout()).toMatch(/Nothing to report today/); + expect(out.stdout()).not.toMatch(/Follow-ups & promises/); + }); + + it("does not print the empty-state fallback when follow-ups has an answer", async () => { + mockFetch(jsonResponse({ ...BRIEF, reminders: { overdue: [], dueToday: [], upcoming: [] }, importantDates: [], recentMemories: [], meetings: null, counts: null })); + await run(["brief", "--container", "user:me"]); + expect(out.stdout()).toMatch(/You promised Bob a draft by Friday/); + expect(out.stderr() + out.stdout()).not.toMatch(/Nothing to report today/); + }); + + it("surfaces FEATURE_DISABLED (503) as a coded error", async () => { + mockFetch(jsonResponse({ statusCode: 503, code: "FEATURE_DISABLED", message: "brief is not enabled for this deployment.", error: "Service Unavailable" }, 503)); + await expect(run(["brief", "--container", "user:me"])).rejects.toMatchObject({ status: 503, code: "FEATURE_DISABLED" }); + }); +}); diff --git a/src/commands/brief.ts b/src/commands/brief.ts new file mode 100644 index 0000000..9db1fe9 --- /dev/null +++ b/src/commands/brief.ts @@ -0,0 +1,111 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient } from "../lib/client.js"; +import { CONTAINER_OPTION_DESC, CONTAINER_OPTION_FLAGS, requireContainerTag } from "../lib/container.js"; +import { + failUsage, + formatWhen, + oneLine, + parseIntFlag, + printHeading, + printInfo, + printJson, + printLine, + rootJsonFlag, + systemTimezone, +} from "../lib/output.js"; +import type { DailyBrief } from "../lib/personal-types.js"; +import { printImportantDates, printReminderSection } from "./reminders.js"; + +const SECTIONS = ["core", "followUps", "meetings"] as const; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +interface BriefOpts { + container?: string; + date?: string; + timezone?: string; + days?: string; + sections?: string; +} + +/** Validates a csv of sections (order preserved, duplicates dropped) or exit 2. */ +export function parseSections(raw: string | undefined, json: boolean): string | undefined { + if (raw === undefined) return undefined; + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + const unique = [...new Set(parts)]; + if (unique.length === 0 || unique.some((s) => !(SECTIONS as readonly string[]).includes(s))) { + return failUsage(json, "invalid_argument", `--sections must be a comma-separated subset of: ${SECTIONS.join(", ")}`); + } + return unique.join(","); +} + +function printBrief(brief: DailyBrief, days: number): void { + const scope = brief.scope.containerTag ? brief.scope.containerTag : "workspace"; + printHeading(`Daily brief — ${brief.date} ${kleur.dim(`(${brief.timezone} · ${scope})`)}`); + let printed = 0; + if (brief.reminders) { + printed += brief.reminders.overdue.length + brief.reminders.dueToday.length + brief.reminders.upcoming.length; + printReminderSection(kleur.red("Overdue"), brief.reminders.overdue); + printReminderSection("Due today", brief.reminders.dueToday); + printReminderSection(`Coming up (${days}d)`, brief.reminders.upcoming); + } + if (brief.importantDates) { + printed += brief.importantDates.length; + printImportantDates(brief.importantDates); + } + if (brief.meetings && brief.meetings.length > 0) { + printed += brief.meetings.length; + printHeading("Today's meetings"); + for (const m of brief.meetings) { + const who = m.attendees.filter((a) => !a.self).map((a) => a.person?.displayName ?? a.name ?? a.email ?? "?"); + printLine(` ${kleur.yellow(formatWhen(m.start))} ${m.title}${who.length ? kleur.dim(` · ${who.join(", ")}`) : ""}`); + } + } + if (brief.followUps) { + if (!brief.followUps.abstained) printed += 1; + printHeading("Follow-ups & promises"); + printLine(brief.followUps.abstained ? kleur.dim(" Nothing outstanding that I can find.") : ` ${brief.followUps.answer}`); + } + if (brief.recentMemories && brief.recentMemories.length > 0) { + printed += brief.recentMemories.length; + printHeading("Captured in the last 24h"); + for (const m of brief.recentMemories) { + const by = m.createdBy?.label ?? m.createdBy?.kind ?? ""; + printLine(` ${kleur.dim(formatWhen(m.createdAt))} ${oneLine(m.content)}${by ? kleur.dim(` · ${by}`) : ""}`); + } + } + if (brief.counts) { + printInfo(`${brief.counts.memoriesLast24h} memories, ${brief.counts.documentsLast24h} documents in the last 24h`); + } + if (printed === 0) printInfo("Nothing to report today."); +} + +export function registerBriefCommand(program: Command): void { + program + .command("brief") + .description("your daily brief: reminders, important dates, meetings, follow-ups, recent captures") + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) + .option("--date ", "local date of the brief (default: today)") + .option("--timezone ", "IANA timezone (default: system timezone)") + .option("--days ", "look-ahead window in days (1-30)", "7") + .option("--sections ", "subset of core,followUps,meetings (default: all)") + .action(async (opts: BriefOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const days = parseIntFlag(opts.days, "--days", json, { min: 1, max: 30, fallback: 7 }); + if (opts.date !== undefined && !DATE_RE.test(opts.date)) { + return failUsage(json, "invalid_argument", "--date must be YYYY-MM-DD"); + } + const sections = parseSections(opts.sections, json); + const ctx = await getClient(); + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); + const brief = await ctx.api.get("/v1/brief", { + containerTag, + date: opts.date, + timezone: opts.timezone ?? systemTimezone(), + days, + sections, + }); + if (json) return printJson(brief); + printBrief(brief, days); + }); +} diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts new file mode 100644 index 0000000..d075ade --- /dev/null +++ b/src/commands/mcp.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { installHarness, run } from "../test/harness.js"; + +describe("getmnemo mcp", () => { + const out = installHarness(); + + it("emits the published getmnemo-mcp package in the npx snippet", async () => { + await run(["--json", "mcp"]); + const payload = JSON.parse(out.stdout()) as { + client: string; + snippet: { mcpServers: { getmnemo: { command: string; args: string[]; env: Record } } }; + }; + expect(payload.client).toBe("claude"); + expect(payload.snippet.mcpServers.getmnemo.command).toBe("npx"); + expect(payload.snippet.mcpServers.getmnemo.args).toEqual(["-y", "getmnemo-mcp"]); + expect(payload.snippet.mcpServers.getmnemo.env.GETMNEMO_API_KEY).toBe("mk_test_key"); + expect(out.stdout()).not.toMatch(/@mnemo\/mcp/); + }); + + it("targets the cursor config path with --client cursor", async () => { + await run(["mcp", "--client", "cursor"]); + expect(out.stdout()).toMatch(/~\/\.cursor\/mcp\.json/); + expect(out.stdout()).toMatch(/getmnemo-mcp/); + }); +}); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d4b9359..042869d 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -18,7 +18,9 @@ export function registerMcpCommand(program: Command): void { mcpServers: { getmnemo: { command: "npx", - args: ["-y", "@mnemo/mcp"], + // Published package is `getmnemo-mcp` (bin `getmnemo-mcp`); the old + // `@mnemo/mcp` name was never published. + args: ["-y", "getmnemo-mcp"], env: { GETMNEMO_API_KEY: apiKey, GETMNEMO_WORKSPACE_ID: workspaceId, diff --git a/src/commands/meetings.test.ts b/src/commands/meetings.test.ts new file mode 100644 index 0000000..767d2c4 --- /dev/null +++ b/src/commands/meetings.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; + +const MEETING = { + documentId: "11111111-1111-4111-8111-111111111111", + eventId: "e1", + title: "Sync with Jane", + start: "2026-09-03T10:00:00+05:00", + end: "2026-09-03T10:30:00+05:00", + isAllDay: false, + status: "confirmed", + htmlLink: null, + location: "Zoom", + organizer: { email: "me@example.com", name: "Me" }, + attendees: [ + { email: "me@example.com", name: "Me", responseStatus: "accepted", self: true, person: null }, + { email: "jane@example.com", name: "Jane", responseStatus: "accepted", self: false, person: { slug: "jane-doe", displayName: "Jane Doe" } }, + ], + containerTag: "calendar:primary", + connectionId: "c1", + attendeeSource: "metadata", +}; + +describe("getmnemo meetings", () => { + const out = installHarness(); + + describe("upcoming", () => { + it("GETs /v1/meetings/upcoming with days, limit, cursor and container", async () => { + const fetchMock = mockFetch(jsonResponse({ items: [MEETING], nextCursor: null, connections: [{ id: "c1", containerTag: "calendar:primary", status: "active", lastSyncAt: null }] })); + await run(["--json", "meetings", "upcoming", "--days", "3", "--limit", "5", "--cursor", "x", "--container", "calendar:primary"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/meetings/upcoming?days=3&limit=5&cursor=x&containerTag=calendar%3Aprimary`); + expect(JSON.parse(out.stdout())).toMatchObject({ items: [{ documentId: MEETING.documentId }] }); + }); + + it("renders rows with resolved people; defaults days=7", async () => { + const fetchMock = mockFetch(jsonResponse({ items: [MEETING], nextCursor: null, connections: [{ id: "c1", containerTag: "calendar:primary", status: "active", lastSyncAt: null }] })); + await run(["meetings", "upcoming"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/meetings/upcoming?days=7&limit=50`); + expect(out.stdout()).toMatch(/Sync with Jane/); + expect(out.stdout()).toMatch(/Jane Doe \(jane-doe\)/); + expect(out.stdout()).not.toMatch(/Me/); + }); + + it("tells the user to connect a calendar when there are no connections", async () => { + mockFetch(jsonResponse({ items: [], nextCursor: null, connections: [] })); + await run(["meetings", "upcoming"]); + expect(out.stdout()).toMatch(/No calendar connected/); + }); + + it("rejects --days above 30", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["meetings", "upcoming", "--days", "31"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("brief", () => { + const BRIEF = { + ...MEETING, + brief: { answer: "Jane wants the revised pricing before Friday.", citations: [], abstained: false, cached: false }, + people: [{ slug: "jane-doe", displayName: "Jane Doe", relationship: "client", openReminders: [], recentMemories: [{ id: "m1", content: "Jane asked for SOC 2 timeline", memoryType: "note", metadata: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", dueAt: null, createdBy: null }] }], + previousMeetings: [{ documentId: "22222222-2222-4222-8222-222222222222", title: "Kickoff", start: "2026-08-20T10:00:00+05:00" }], + generatedAt: "2026-09-03T01:00:00.000Z", + }; + + it("GETs /v1/meetings/{id}/brief with an optional question", async () => { + const fetchMock = mockFetch(jsonResponse(BRIEF)); + await run(["--json", "meetings", "brief", MEETING.documentId, "-q", "what did we promise?"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/meetings/${MEETING.documentId}/brief?q=what+did+we+promise%3F`); + expect(JSON.parse(out.stdout())).toMatchObject({ brief: { abstained: false } }); + }); + + it("renders brief, people and previous meetings", async () => { + const fetchMock = mockFetch(jsonResponse(BRIEF)); + await run(["meetings", "brief", MEETING.documentId]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/meetings/${MEETING.documentId}/brief`); + const text = out.stdout(); + expect(text).toMatch(/Sync with Jane/); + expect(text).toMatch(/Zoom/); + expect(text).toMatch(/Jane wants the revised pricing before Friday/); + expect(text).toMatch(/Jane asked for SOC 2 timeline/); + expect(text).toMatch(/Previous meetings/); + expect(text).toMatch(/Kickoff/); + }); + + it("surfaces MEETING_NOT_FOUND", async () => { + mockFetch(jsonResponse({ statusCode: 404, code: "MEETING_NOT_FOUND", message: "Meeting not found", error: "Not Found" }, 404)); + await expect(run(["meetings", "brief", MEETING.documentId])).rejects.toMatchObject({ status: 404, code: "MEETING_NOT_FOUND" }); + }); + }); +}); diff --git a/src/commands/meetings.ts b/src/commands/meetings.ts new file mode 100644 index 0000000..0330802 --- /dev/null +++ b/src/commands/meetings.ts @@ -0,0 +1,104 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient } from "../lib/client.js"; +import { CONTAINER_OPTION_FLAGS } from "../lib/container.js"; +import { + formatWhen, + oneLine, + parseIntFlag, + printHeading, + printInfo, + printJson, + printLine, + rootJsonFlag, +} from "../lib/output.js"; +import type { UpcomingMeetings, Meeting, MeetingBrief } from "../lib/personal-types.js"; +import { reminderLine } from "./reminders.js"; + +interface UpcomingOpts { + days?: string; + limit?: string; + cursor?: string; + container?: string; +} + +interface BriefOpts { + q?: string; +} + +function attendeeNames(m: Meeting): string[] { + return m.attendees + .filter((a) => !a.self) + .map((a) => (a.person ? `${a.person.displayName}${kleur.dim(` (${a.person.slug})`)}` : a.name ?? a.email ?? "?")); +} + +function meetingLine(m: Meeting): string { + const when = m.isAllDay && m.start ? m.start.slice(0, 10) : formatWhen(m.start); + const who = attendeeNames(m); + return `${kleur.dim(m.documentId)} ${kleur.yellow(when)} ${m.title}${who.length ? kleur.dim(` · ${who.join(", ")}`) : ""}`; +} + +function printMeetingBrief(b: MeetingBrief): void { + printHeading(`${b.title} ${kleur.dim(`— ${formatWhen(b.start)}${b.location ? ` · ${b.location}` : ""}`)}`); + const who = attendeeNames(b); + if (who.length) printInfo(`with: ${who.join(", ")}`); + if (b.brief) { + printHeading("Brief"); + printLine(b.brief.abstained ? kleur.dim(" Nothing in memory prepares you for this one yet.") : ` ${b.brief.answer}`); + } + for (const p of b.people) { + printHeading(`${p.displayName}${p.relationship ? kleur.dim(` · ${p.relationship}`) : ""}`); + for (const r of p.openReminders) printLine(` ${reminderLine(r)}`); + for (const m of p.recentMemories) printLine(` ${kleur.dim(formatWhen(m.createdAt))} ${oneLine(m.content)}`); + if (p.openReminders.length === 0 && p.recentMemories.length === 0) printLine(kleur.dim(" no open reminders or recent memories")); + } + if (b.previousMeetings.length > 0) { + printHeading("Previous meetings"); + for (const pm of b.previousMeetings) printLine(` ${kleur.dim(formatWhen(pm.start))} ${pm.title} ${kleur.dim(pm.documentId)}`); + } +} + +export function registerMeetingsCommands(program: Command): void { + const meetings = program.command("meetings").description("upcoming calendar meetings and pre-meeting briefs"); + + meetings + .command("upcoming") + .description("list upcoming meetings from connected calendars") + .option("--days ", "window in days (1-30)", "7") + .option("-l, --limit ", "page size (1-100)", "50") + .option("-c, --cursor ", "pagination cursor") + .option(CONTAINER_OPTION_FLAGS, "only one calendar connection's container (explicit flag only)") + .action(async (opts: UpcomingOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const days = parseIntFlag(opts.days, "--days", json, { min: 1, max: 30, fallback: 7 }); + const limit = parseIntFlag(opts.limit, "--limit", json, { min: 1, max: 100, fallback: 50 }); + const ctx = await getClient(); + const result = await ctx.api.get("/v1/meetings/upcoming", { + days, + limit, + cursor: opts.cursor, + containerTag: opts.container, + }); + if (json) return printJson(result); + if (result.connections.length === 0) { + return printInfo("No calendar connected. Connect Google Calendar in the dashboard (Connectors)."); + } + if (result.items.length === 0) return printInfo(`No meetings in the next ${days} days.`); + for (const m of result.items) printLine(meetingLine(m)); + if (result.nextCursor) printInfo(`more: --cursor ${result.nextCursor}`); + }); + + meetings + .command("brief ") + .description("pre-meeting brief: attendees' memories, open reminders, previous meetings") + .option("-q, --q ", "focus the brief on a question") + .action(async (documentId: string, opts: BriefOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const ctx = await getClient(); + const brief = await ctx.api.get(`/v1/meetings/${encodeURIComponent(documentId)}/brief`, { + q: opts.q, + }); + if (json) return printJson(brief); + printMeetingBrief(brief); + }); +} diff --git a/src/commands/memories.test.ts b/src/commands/memories.test.ts new file mode 100644 index 0000000..2f60d85 --- /dev/null +++ b/src/commands/memories.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import prompts from "prompts"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; + +const A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const C = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + +const MERGED = { + memory: { id: C, content: "merged", memoryType: "memory", metadata: null, createdAt: "2026-09-03T00:00:00.000Z", updatedAt: "2026-09-03T00:00:00.000Z", dueAt: null, createdBy: null }, + mergedFromIds: [A, B], + deletedIds: [A, B], + replayed: false, +}; + +describe("getmnemo memories merge", () => { + const out = installHarness(); + + it("POSTs /v1/memories/merge creating a new survivor (--content) with --yes", async () => { + const fetchMock = mockFetch(jsonResponse(MERGED, 201)); + await run([ + "memories", "merge", A, B, "--yes", "--container", "user:me", + "--content", "Jane prefers blue and green", "--type", "preference", "--merge-key", "k1", "-m", "reason=dup", + ]); + const req = requestAt(fetchMock); + expect(req.method).toBe("POST"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/memories/merge`); + expect(req.body).toEqual({ + containerTag: "user:me", + ids: [A, B], + content: "Jane prefers blue and green", + memoryType: "preference", + metadata: { reason: "dup" }, + mergeKey: "k1", + }); + expect(out.stdout()).toMatch(new RegExp(`Merged 2 memories into ${C} \\(2 soft-deleted, restorable\\)`)); + }); + + it("POSTs with --into and no content; reports a replay", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = mockFetch(jsonResponse({ ...MERGED, memory: { ...MERGED.memory, id: A }, deletedIds: [B], replayed: true })); + await run(["--json", "memories", "merge", A, B, "--into", A, "-y"]); + expect(requestAt(fetchMock).body).toEqual({ containerTag: "user:env", ids: [A, B], into: A }); + expect(JSON.parse(out.stdout())).toMatchObject({ replayed: true }); + }); + + it("dedupes repeated ids before sending", async () => { + const fetchMock = mockFetch(jsonResponse(MERGED, 201)); + await run(["memories", "merge", A, B, A, "-y", "-C", "user:me", "--into", B]); + expect(requestAt(fetchMock).body).toEqual({ containerTag: "user:me", ids: [A, B], into: B }); + }); + + it("exits 2 with fewer than two distinct ids", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["--json", "memories", "merge", A, A, "-y", "-C", "user:me", "--into", A], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/invalid_argument/); + }); + + it("exits 2 when --into is not one of the ids", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["memories", "merge", A, B, "-y", "-C", "user:me", "--into", C], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--into must be one of the ids/); + }); + + it("exits 2 when neither --into nor --content is given", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["memories", "merge", A, B, "-y", "-C", "user:me"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--content is required/); + }); + + it("exits 2 without a container before prompting or sending", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["memories", "merge", A, B, "--into", A], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/A container is required/); + }); + + it("refuses without --yes in a non-interactive shell", async () => { + const fetchMock = mockFetch(); + const isTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + try { + await runExpectingExit(["--json", "memories", "merge", A, B, "--into", A, "-C", "user:me"], 2); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true }); + } + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/confirmation_required/); + }); + + it("cancels cleanly when the interactive prompt is declined", async () => { + const fetchMock = mockFetch(); + const isTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + prompts.inject([false]); + try { + await run(["memories", "merge", A, B, "--into", A, "-C", "user:me"]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true }); + } + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/Cancelled/); + }); + + it("surfaces MERGE_CROSS_CONTAINER (400) as a coded error", async () => { + mockFetch(jsonResponse({ statusCode: 400, code: "MERGE_CROSS_CONTAINER", message: "Memories must share a container", error: "Bad Request" }, 400)); + await expect(run(["memories", "merge", A, B, "--into", A, "-y", "-C", "user:me"])).rejects.toMatchObject({ status: 400, code: "MERGE_CROSS_CONTAINER" }); + }); +}); diff --git a/src/commands/memories.ts b/src/commands/memories.ts new file mode 100644 index 0000000..40120b0 --- /dev/null +++ b/src/commands/memories.ts @@ -0,0 +1,85 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import prompts from "prompts"; +import { getClient, parseMetadata } from "../lib/client.js"; +import { CONTAINER_OPTION_DESC, CONTAINER_OPTION_FLAGS, requireContainerTag } from "../lib/container.js"; +import { failUsage, printInfo, printJson, printSuccess, rootJsonFlag } from "../lib/output.js"; +import type { MergeMemoriesInput, MergeMemoriesResponse } from "../lib/personal-types.js"; + +const MIN_MERGE_IDS = 2; +const MAX_MERGE_IDS = 20; + +interface MergeOpts { + into?: string; + content?: string; + type?: string; + mergeKey?: string; + metadata?: string[]; + container?: string; + yes?: boolean; +} + +/** Local validation of the merge set (mirrors MergeMemoriesDto) or exit 2. */ +export function validateMergeIds(ids: string[], into: string | undefined, json: boolean): string[] { + const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; + if (unique.length < MIN_MERGE_IDS || unique.length > MAX_MERGE_IDS) { + return failUsage(json, "invalid_argument", `merge needs between ${MIN_MERGE_IDS} and ${MAX_MERGE_IDS} distinct memory ids.`); + } + if (into !== undefined && !unique.includes(into)) { + return failUsage(json, "invalid_argument", "--into must be one of the ids being merged."); + } + return unique; +} + +export function registerMemoriesCommands(program: Command): void { + const memories = program.command("memories").description("bulk memory operations"); + + memories + .command("merge ") + .description("merge 2-20 memories into one (sources are soft-deleted and restorable)") + .option("--into ", "keep this memory as the survivor (must be one of the ids)") + .option("--content ", "content of the merged memory (required without --into)") + .option("--type ", "memoryType for a newly created survivor") + .option("--merge-key ", "idempotency key (default: hash of the sorted ids)") + .option("-m, --metadata ", "metadata key=value pairs (repeatable)") + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) + .option("-y, --yes", "skip confirmation prompt", false) + .action(async (rawIds: string[], opts: MergeOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const ids = validateMergeIds(rawIds, opts.into, json); + if (opts.into === undefined && !opts.content?.trim()) { + return failUsage(json, "invalid_argument", "--content is required when no --into survivor is given."); + } + const metadata = parseMetadata(opts.metadata); + const ctx = await getClient(); + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); + const sources = ids.filter((id) => id !== opts.into); + if (!opts.yes) { + if (!process.stdin.isTTY) { + return failUsage(json, "confirmation_required", "Refusing to merge without --yes in a non-interactive shell."); + } + const { confirm } = await prompts({ + type: "confirm", + name: "confirm", + message: `Merge ${ids.length} memories${opts.into ? ` into ${opts.into}` : ""}? ${sources.length} source(s) will be soft-deleted.`, + initial: false, + }); + if (!confirm) return printInfo("Cancelled."); + } + const body: MergeMemoriesInput = { + containerTag, + ids, + into: opts.into, + content: opts.content?.trim() || undefined, + memoryType: opts.type, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + mergeKey: opts.mergeKey, + }; + const result = await ctx.api.post("/v1/memories/merge", body); + if (json) return printJson(result); + const verb = result.replayed ? "Already merged" : "Merged"; + printSuccess( + `${verb} ${result.mergedFromIds.length} memories into ${kleur.dim(result.memory.id)} (${result.deletedIds.length} soft-deleted, restorable)`, + ); + }); +} diff --git a/src/commands/memory.ts b/src/commands/memory.ts index b73a998..24b94e8 100644 --- a/src/commands/memory.ts +++ b/src/commands/memory.ts @@ -3,7 +3,11 @@ import kleur from "kleur"; import prompts from "prompts"; import type { Memory, SearchHit } from "getmnemo"; import { getClient, parseMetadata } from "../lib/client.js"; -import { resolveContainerTag, type CliConfig } from "../lib/config.js"; +import { + CONTAINER_OPTION_DESC, + CONTAINER_OPTION_FLAGS, + requireContainerTag, +} from "../lib/container.js"; import { printError, printInfo, @@ -19,28 +23,6 @@ function memoryId(m: Memory | SearchHit): string { return "memoryId" in m ? m.memoryId : m.id; } -const CONTAINER_OPTION_FLAGS = "-C, --container "; -const CONTAINER_OPTION_DESC = - "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config"; - -// Every memory command needs a container as of getmnemo 0.5.1: the API 400s -// by-id get/delete without a scope (requireMemoryScope guard), and the SDK -// itself throws on add/search/list. Resolve or exit(2) before any request. -function requireContainerTag(cfg: CliConfig, flag: string | undefined, json: boolean): string { - const containerTag = resolveContainerTag(cfg, flag); - if (!containerTag) { - if (json) { - printJson({ ok: false, error: "container_required" }); - } else { - printError( - "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", - ); - } - process.exit(2); - } - return containerTag; -} - export function registerMemoryCommands(program: Command): void { program .command("add ") diff --git a/src/commands/people.test.ts b/src/commands/people.test.ts new file mode 100644 index 0000000..896b489 --- /dev/null +++ b/src/commands/people.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; +import { PersonalApiError } from "../lib/personal-api.js"; + +const PERSON = { + slug: "jane-doe", + tag: "person:jane-doe", + containerId: "c1", + displayName: "Jane Doe", + relationship: "client", + email: "jane@example.com", + phone: null, + company: "Acme", + notes: null, + importantDates: [{ label: "birthday", date: "1990-05-04", recurring: true }], + aliases: ["JD"], + archivedAt: null, + memoryCount: 3, + openReminderCount: 1, + nextReminderAt: "2026-09-05T09:00:00.000Z", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", +}; + +describe("getmnemo people", () => { + const out = installHarness(); + + describe("list", () => { + it("GETs /v1/people with q, includeArchived, limit and cursor", async () => { + const fetchMock = mockFetch(jsonResponse({ items: [PERSON], nextCursor: "next1", total: 1 })); + await run(["--json", "people", "list", "-q", "jane", "--include-archived", "--limit", "10", "--cursor", "abc"]); + const req = requestAt(fetchMock); + expect(req.method).toBe("GET"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/people?limit=10&cursor=abc&q=jane&includeArchived=true`); + expect(req.headers.authorization).toBe("Bearer mk_test_key"); + expect(JSON.parse(out.stdout())).toMatchObject({ items: [{ slug: "jane-doe" }], nextCursor: "next1" }); + }); + + it("omits optional params when not given and renders rows", async () => { + const fetchMock = mockFetch(jsonResponse({ items: [PERSON], nextCursor: null, total: 1 })); + await run(["people", "list"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/people?limit=50`); + expect(out.stdout()).toMatch(/jane-doe/); + expect(out.stdout()).toMatch(/Jane Doe/); + expect(out.stdout()).toMatch(/3 memories, 1 open reminders/); + }); + + it("prints an empty hint when there are no people", async () => { + mockFetch(jsonResponse({ items: [], nextCursor: null, total: 0 })); + await run(["people", "list"]); + expect(out.stdout()).toMatch(/No people yet/); + }); + + it("rejects an out-of-range --limit before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["--json", "people", "list", "--limit", "500"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/"error": "invalid_argument"/); + }); + }); + + describe("get", () => { + it("GETs /v1/people/{slug} (url-encoded) and renders the person", async () => { + const fetchMock = mockFetch(jsonResponse(PERSON)); + await run(["people", "get", "jane doe"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/people/jane%20doe`); + expect(out.stdout()).toMatch(/Jane Doe/); + expect(out.stdout()).toMatch(/person:jane-doe/); + expect(out.stdout()).toMatch(/relationship: client/); + expect(out.stdout()).toMatch(/birthday 1990-05-04 \(recurring\)/); + expect(out.stdout()).toMatch(/next 2026-09-05 09:00Z/); + }); + + it("surfaces PERSON_NOT_FOUND with status 404", async () => { + mockFetch(jsonResponse({ statusCode: 404, code: "PERSON_NOT_FOUND", message: "Person not found", error: "Not Found" }, 404)); + await expect(run(["--json", "people", "get", "nobody"])).rejects.toMatchObject({ + name: "PersonalApiError", + status: 404, + code: "PERSON_NOT_FOUND", + }); + }); + }); + + describe("add", () => { + it("POSTs /v1/people with only the provided fields", async () => { + const fetchMock = mockFetch(jsonResponse(PERSON, 201)); + await run([ + "people", "add", "Jane Doe", + "--relationship", "client", "--email", "jane@example.com", "--company", "Acme", + "--alias", "JD", "Janie", + "--important-date", "birthday=1990-05-04:recurring", "anniversary=2020-06-01", + ]); + const req = requestAt(fetchMock); + expect(req.method).toBe("POST"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/people`); + expect(req.headers["content-type"]).toBe("application/json"); + expect(req.body).toEqual({ + displayName: "Jane Doe", + relationship: "client", + email: "jane@example.com", + company: "Acme", + aliases: ["JD", "Janie"], + importantDates: [ + { label: "birthday", date: "1990-05-04", recurring: true }, + { label: "anniversary", date: "2020-06-01", recurring: false }, + ], + }); + expect(out.stdout()).toMatch(/Added Jane Doe/); + }); + + it("sends the bare minimum body when only a name is given", async () => { + const fetchMock = mockFetch(jsonResponse(PERSON, 201)); + await run(["--json", "people", "add", "Jane Doe", "--slug", "jane"]); + expect(requestAt(fetchMock).body).toEqual({ displayName: "Jane Doe", slug: "jane" }); + expect(JSON.parse(out.stdout())).toMatchObject({ slug: "jane-doe" }); + }); + + it("rejects a malformed --important-date before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["people", "add", "Jane", "--important-date", "birthday=May 4"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/Invalid --important-date/); + }); + + it("maps PERSON_EXISTS 409 to a PersonalApiError", async () => { + mockFetch(jsonResponse({ statusCode: 409, code: "PERSON_EXISTS", message: "A person with slug jane already exists", error: "Conflict" }, 409)); + const err = await run(["people", "add", "Jane", "--slug", "jane"]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PersonalApiError); + expect((err as PersonalApiError).code).toBe("PERSON_EXISTS"); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/commands/people.ts b/src/commands/people.ts new file mode 100644 index 0000000..63b1345 --- /dev/null +++ b/src/commands/people.ts @@ -0,0 +1,158 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient } from "../lib/client.js"; +import { + failUsage, + formatWhen, + parseIntFlag, + printHeading, + printInfo, + printJson, + printLine, + printSuccess, + rootJsonFlag, + truncate, +} from "../lib/output.js"; +import type { + CreatePersonInput, + PersonImportantDate, + PaginatedPeople, + Person, +} from "../lib/personal-types.js"; + +interface ListOpts { + q?: string; + includeArchived?: boolean; + limit?: string; + cursor?: string; +} + +interface AddOpts { + slug?: string; + relationship?: string; + email?: string; + phone?: string; + company?: string; + notes?: string; + alias?: string[]; + importantDate?: string[]; +} + +const IMPORTANT_DATE_RE = /^(\d{4}-\d{2}-\d{2})(?::(recurring))?$/; + +/** `label=YYYY-MM-DD[:recurring]` → PersonImportantDate, or exit 2. */ +export function parseImportantDates(raw: string[] | undefined, json: boolean): PersonImportantDate[] | undefined { + if (!raw || raw.length === 0) return undefined; + return raw.map((entry) => { + const idx = entry.indexOf("="); + const label = idx === -1 ? "" : entry.slice(0, idx).trim(); + const match = idx === -1 ? null : IMPORTANT_DATE_RE.exec(entry.slice(idx + 1).trim()); + if (!label || !match || !match[1]) { + return failUsage( + json, + "invalid_argument", + `Invalid --important-date "${entry}". Expected label=YYYY-MM-DD or label=YYYY-MM-DD:recurring.`, + ); + } + return { label, date: match[1], recurring: match[2] === "recurring" }; + }); +} + +function printPerson(person: Person): void { + printHeading(`${person.displayName} ${kleur.dim(`(${person.tag})`)}`); + if (person.relationship) printInfo(`relationship: ${person.relationship}`); + if (person.company) printInfo(`company: ${person.company}`); + if (person.email) printInfo(`email: ${person.email}`); + if (person.phone) printInfo(`phone: ${person.phone}`); + if (person.aliases.length > 0) printInfo(`aliases: ${person.aliases.join(", ")}`); + printInfo(`memories: ${person.memoryCount}`); + printInfo( + `reminders: ${person.openReminderCount} open${person.nextReminderAt ? `, next ${formatWhen(person.nextReminderAt)}` : ""}`, + ); + for (const d of person.importantDates) { + printInfo(`date: ${d.label} ${d.date}${d.recurring ? " (recurring)" : ""}`); + } + if (person.notes) printInfo(`notes: ${truncate(person.notes, 200)}`); + if (person.archivedAt) printInfo(kleur.yellow(`archived: ${formatWhen(person.archivedAt)}`)); +} + +export function registerPeopleCommands(program: Command): void { + const people = program.command("people").description("manage people (one memory container per person)"); + + people + .command("list") + .description("list people in the workspace") + .option("-q, --q ", "filter by name or email") + .option("--include-archived", "include archived people", false) + .option("-l, --limit ", "page size (1-100)", "50") + .option("-c, --cursor ", "pagination cursor") + .action(async (opts: ListOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const limit = parseIntFlag(opts.limit, "--limit", json, { min: 1, max: 100, fallback: 50 }); + const ctx = await getClient(); + const result = await ctx.api.get("/v1/people", { + limit, + cursor: opts.cursor, + q: opts.q, + includeArchived: opts.includeArchived ? true : undefined, + }); + if (json) return printJson(result); + if (result.items.length === 0) return printInfo("No people yet. Add one with `getmnemo people add `."); + for (const p of result.items) { + const slug = kleur.dim(p.slug.padEnd(24).slice(0, 24)); + const rel = p.relationship ? kleur.dim(` · ${p.relationship}`) : ""; + const archived = p.archivedAt ? kleur.yellow(" [archived]") : ""; + printLine( + `${slug} ${p.displayName}${rel}${archived} ${kleur.dim(`${p.memoryCount} memories, ${p.openReminderCount} open reminders`)}`, + ); + } + if (result.nextCursor) printInfo(`more: --cursor ${result.nextCursor}`); + }); + + people + .command("get ") + .description("show a person") + .action(async (slug: string, _opts: unknown, cmd: Command) => { + const json = rootJsonFlag(cmd); + const ctx = await getClient(); + const person = await ctx.api.get(`/v1/people/${encodeURIComponent(slug)}`); + if (json) return printJson(person); + printPerson(person); + }); + + people + .command("add ") + .description("add a person") + .option("--slug ", "explicit slug (default: derived from the name)") + .option("--relationship ", "e.g. friend, client, manager") + .option("--email ") + .option("--phone ", "E.164 preferred, e.g. +14155550123") + .option("--company ") + .option("--notes ") + .option("--alias ", "alternate names (repeatable)") + .option( + "--important-date ", + "important dates as label=YYYY-MM-DD[:recurring], e.g. birthday=1990-05-04:recurring (repeatable)", + ) + .action(async (displayName: string, opts: AddOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const name = displayName.trim(); + if (!name) return failUsage(json, "invalid_argument", "displayName must not be empty"); + const importantDates = parseImportantDates(opts.importantDate, json); + const body: CreatePersonInput = { + displayName: name, + slug: opts.slug, + relationship: opts.relationship, + email: opts.email, + phone: opts.phone, + company: opts.company, + notes: opts.notes, + aliases: opts.alias && opts.alias.length > 0 ? opts.alias : undefined, + importantDates, + }; + const ctx = await getClient(); + const person = await ctx.api.post("/v1/people", body); + if (json) return printJson(person); + printSuccess(`Added ${person.displayName} ${kleur.dim(`(${person.tag})`)}`); + }); +} diff --git a/src/commands/reminders.test.ts b/src/commands/reminders.test.ts new file mode 100644 index 0000000..8a0654e --- /dev/null +++ b/src/commands/reminders.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; + +const REMINDER = { + id: "r1", + content: "Send Jane the proposal", + memoryType: "reminder", + metadata: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + dueAt: "2026-09-05T09:00:00.000Z", + createdBy: { kind: "api_key", id: "k1", label: "cli" }, + completedAt: null, + person: { slug: "jane-doe", displayName: "Jane Doe" }, +}; + +describe("getmnemo reminders", () => { + const out = installHarness(); + + describe("list", () => { + it("GETs /v1/reminders with every filter", async () => { + const fetchMock = mockFetch(jsonResponse({ items: [REMINDER], nextCursor: null, total: 1 })); + await run([ + "--json", "reminders", "list", "--status", "all", "--days", "30", + "--due-after", "2026-09-01T00:00:00Z", "--due-before", "2026-10-01T00:00:00Z", + "--container", "person:jane-doe", "--container-type", "person", "--limit", "5", "--cursor", "cur", + ]); + expect(requestAt(fetchMock).url).toBe( + `${TEST_BASE_URL}/v1/reminders?status=all&days=30&dueAfter=2026-09-01T00%3A00%3A00Z&dueBefore=2026-10-01T00%3A00%3A00Z&containerTag=person%3Ajane-doe&containerType=person&limit=5&cursor=cur`, + ); + expect(JSON.parse(out.stdout())).toMatchObject({ items: [{ id: "r1" }] }); + }); + + it("defaults to status=open and does NOT apply GETMNEMO_CONTAINER (tenant-wide list)", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = mockFetch(jsonResponse({ items: [REMINDER], nextCursor: null, total: 1 })); + await run(["reminders", "list"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/reminders?status=open&limit=50`); + expect(out.stdout()).toMatch(/r1/); + expect(out.stdout()).toMatch(/2026-09-05 09:00Z/); + expect(out.stdout()).toMatch(/Send Jane the proposal/); + expect(out.stdout()).toMatch(/Jane Doe/); + }); + + it("rejects an unknown --status before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["reminders", "list", "--status", "done"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--status must be one of: open, completed, all/); + }); + + it("rejects a non-ISO --due-after before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["--json", "reminders", "list", "--due-after", "next tuesday"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/invalid_argument/); + }); + }); + + describe("upcoming", () => { + const BUCKETS = { + overdue: [{ ...REMINDER, id: "r0", dueAt: "2026-08-30T09:00:00.000Z" }], + dueToday: [REMINDER], + upcoming: [], + importantDates: [{ personSlug: "jane-doe", displayName: "Jane Doe", label: "birthday", date: "2026-09-04", daysUntil: 1, recurring: true }], + generatedAt: "2026-09-03T00:00:00.000Z", + timezone: "Asia/Karachi", + }; + + it("GETs /v1/reminders/upcoming with explicit days + timezone", async () => { + const fetchMock = mockFetch(jsonResponse(BUCKETS)); + await run(["--json", "reminders", "upcoming", "--days", "14", "--timezone", "Asia/Karachi", "--limit", "10"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/reminders/upcoming?days=14&timezone=Asia%2FKarachi&limit=10`); + }); + + it("defaults timezone to the system timezone and days to 7", async () => { + const fetchMock = mockFetch(jsonResponse(BUCKETS)); + await run(["reminders", "upcoming"]); + const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/reminders/upcoming?days=7&timezone=${tz}&limit=50`); + expect(out.stdout()).toMatch(/Overdue/); + expect(out.stdout()).toMatch(/Due today/); + expect(out.stdout()).not.toMatch(/Coming up/); + expect(out.stdout()).toMatch(/Important dates/); + expect(out.stdout()).toMatch(/Jane Doe — birthday \(tomorrow\)/); + }); + + it("prints a quiet message when every bucket is empty", async () => { + mockFetch(jsonResponse({ ...BUCKETS, overdue: [], dueToday: [], importantDates: [] })); + await run(["reminders", "upcoming"]); + expect(out.stdout()).toMatch(/Nothing due in the next 7 days/); + }); + + it("rejects --days above 90", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["reminders", "upcoming", "--days", "91"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("add", () => { + it("POSTs /v1/reminders with personSlug when --person is given", async () => { + const fetchMock = mockFetch(jsonResponse(REMINDER, 201)); + await run([ + "reminders", "add", "Send Jane the proposal", "--due", "2026-09-05T09:00:00Z", + "--person", "jane-doe", "--idempotency-key", "prop-1", "-m", "channel=email", + ]); + const req = requestAt(fetchMock); + expect(req.method).toBe("POST"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/reminders`); + expect(req.body).toEqual({ + content: "Send Jane the proposal", + dueAt: "2026-09-05T09:00:00Z", + personSlug: "jane-doe", + idempotencyKey: "prop-1", + metadata: { channel: "email" }, + }); + expect(out.stdout()).toMatch(/Reminder r1 due 2026-09-05 09:00Z/); + }); + + it("POSTs with containerTag resolved from GETMNEMO_CONTAINER when no --person", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:me"); + const fetchMock = mockFetch(jsonResponse(REMINDER, 201)); + await run(["--json", "reminders", "add", "Renew passport", "--due", "2026-12-01"]); + expect(requestAt(fetchMock).body).toEqual({ content: "Renew passport", dueAt: "2026-12-01", containerTag: "user:me" }); + }); + + it("exits 2 when neither --person nor a container resolves", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["--json", "reminders", "add", "x", "--due", "2026-12-01"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stdout()).toMatch(/"error": "container_required"/); + }); + + it("exits 2 when both --person and --container are given", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["reminders", "add", "x", "--due", "2026-12-01", "--person", "jane", "--container", "user:me"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/not both/); + }); + + it("exits 2 on an unparseable --due", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["reminders", "add", "x", "--due", "tomorrow", "--person", "jane"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--due must be an ISO-8601/); + }); + + it("fails when --due is missing (commander mandatory option)", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["reminders", "add", "x", "--person", "jane"], 2); + expect(out.stderr()).toMatch(/required option '--due ' not specified/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("complete", () => { + it("POSTs /v1/reminders/{id}/complete with no body", async () => { + const fetchMock = mockFetch(jsonResponse({ ...REMINDER, dueAt: null, completedAt: "2026-09-03T10:00:00.000Z" })); + await run(["reminders", "complete", "r1"]); + const req = requestAt(fetchMock); + expect(req.method).toBe("POST"); + expect(req.url).toBe(`${TEST_BASE_URL}/v1/reminders/r1/complete`); + expect(req.body).toBeUndefined(); + expect(out.stdout()).toMatch(/Completed r1: Send Jane the proposal/); + }); + + it("surfaces REMINDER_NOT_FOUND", async () => { + mockFetch(jsonResponse({ statusCode: 404, code: "REMINDER_NOT_FOUND", message: "Reminder not found", error: "Not Found" }, 404)); + await expect(run(["reminders", "complete", "nope"])).rejects.toMatchObject({ status: 404, code: "REMINDER_NOT_FOUND" }); + }); + }); +}); diff --git a/src/commands/reminders.ts b/src/commands/reminders.ts new file mode 100644 index 0000000..d993134 --- /dev/null +++ b/src/commands/reminders.ts @@ -0,0 +1,193 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient, parseMetadata } from "../lib/client.js"; +import { CONTAINER_OPTION_DESC, CONTAINER_OPTION_FLAGS } from "../lib/container.js"; +import { resolveContainerTag } from "../lib/config.js"; +import { + failUsage, + formatWhen, + oneLine, + parseIntFlag, + printHeading, + printInfo, + printJson, + printLine, + printSuccess, + requireIsoDate, + requireOneOf, + rootJsonFlag, + systemTimezone, +} from "../lib/output.js"; +import type { + CreateReminderInput, + ImportantDate, + PaginatedReminders, + Reminder, + UpcomingReminders, +} from "../lib/personal-types.js"; + +const STATUSES = ["open", "completed", "all"] as const; + +interface ListOpts { + status?: string; + days?: string; + dueAfter?: string; + dueBefore?: string; + container?: string; + containerType?: string; + limit?: string; + cursor?: string; +} + +interface UpcomingOpts { + days?: string; + timezone?: string; + container?: string; + containerType?: string; + limit?: string; +} + +interface AddOpts { + due?: string; + person?: string; + container?: string; + idempotencyKey?: string; + metadata?: string[]; +} + +export function reminderLine(r: Reminder): string { + const when = r.dueAt ? formatWhen(r.dueAt) : r.completedAt ? `done ${formatWhen(r.completedAt)}` : "—"; + const person = r.person ? kleur.dim(` · ${r.person.displayName}`) : ""; + return `${kleur.dim(r.id)} ${kleur.yellow(when)} ${oneLine(r.content)}${person}`; +} + +export function printReminderSection(title: string, items: Reminder[]): void { + if (items.length === 0) return; + printHeading(title); + for (const r of items) printLine(` ${reminderLine(r)}`); +} + +export function printImportantDates(items: ImportantDate[]): void { + if (items.length === 0) return; + printHeading("Important dates"); + for (const d of items) { + const inDays = d.daysUntil === 0 ? "today" : d.daysUntil === 1 ? "tomorrow" : `in ${d.daysUntil} days`; + printLine(` ${kleur.yellow(d.date)} ${d.displayName} — ${d.label} ${kleur.dim(`(${inDays})`)}`); + } +} + +export function registerRemindersCommands(program: Command): void { + const reminders = program.command("reminders").description("manage reminders (memories with a due date)"); + + reminders + .command("list") + .description("list reminders across the workspace") + .option("-s, --status ", "open | completed | all", "open") + .option("--days ", "only reminders due within n days (1-365)") + .option("--due-after ", "only reminders due at/after this time") + .option("--due-before ", "only reminders due at/before this time") + .option(CONTAINER_OPTION_FLAGS, "only reminders in this container (explicit flag only)") + .option("--container-type ", "only reminders in containers of this type (e.g. person)") + .option("-l, --limit ", "page size (1-100)", "50") + .option("-c, --cursor ", "pagination cursor") + .action(async (opts: ListOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const status = requireOneOf(opts.status, "--status", STATUSES, json, "open"); + const limit = parseIntFlag(opts.limit, "--limit", json, { min: 1, max: 100, fallback: 50 }); + const days = opts.days === undefined ? undefined : parseIntFlag(opts.days, "--days", json, { min: 1, max: 365, fallback: 7 }); + const dueAfter = opts.dueAfter === undefined ? undefined : requireIsoDate(opts.dueAfter, "--due-after", json); + const dueBefore = opts.dueBefore === undefined ? undefined : requireIsoDate(opts.dueBefore, "--due-before", json); + const ctx = await getClient(); + const result = await ctx.api.get("/v1/reminders", { + status, + days, + dueAfter, + dueBefore, + containerTag: opts.container, + containerType: opts.containerType, + limit, + cursor: opts.cursor, + }); + if (json) return printJson(result); + if (result.items.length === 0) return printInfo(`No ${status === "all" ? "" : status + " "}reminders.`); + for (const r of result.items) printLine(reminderLine(r)); + if (result.nextCursor) printInfo(`more: --cursor ${result.nextCursor}`); + }); + + reminders + .command("upcoming") + .description("overdue / due today / coming up, bucketed in your timezone") + .option("--days ", "window in days (1-90)", "7") + .option("--timezone ", "IANA timezone (default: system timezone)") + .option(CONTAINER_OPTION_FLAGS, "only reminders in this container (explicit flag only)") + .option("--container-type ", "only reminders in containers of this type") + .option("-l, --limit ", "max per bucket (1-100)", "50") + .action(async (opts: UpcomingOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const days = parseIntFlag(opts.days, "--days", json, { min: 1, max: 90, fallback: 7 }); + const limit = parseIntFlag(opts.limit, "--limit", json, { min: 1, max: 100, fallback: 50 }); + const ctx = await getClient(); + const result = await ctx.api.get("/v1/reminders/upcoming", { + days, + timezone: opts.timezone ?? systemTimezone(), + containerTag: opts.container, + containerType: opts.containerType, + limit, + }); + if (json) return printJson(result); + const total = result.overdue.length + result.dueToday.length + result.upcoming.length + result.importantDates.length; + if (total === 0) return printInfo(`Nothing due in the next ${days} days.`); + printReminderSection(kleur.red("Overdue"), result.overdue); + printReminderSection("Due today", result.dueToday); + printReminderSection(`Coming up (${days}d)`, result.upcoming); + printImportantDates(result.importantDates); + }); + + reminders + .command("add ") + .description("create a reminder for a person or a container") + .requiredOption("--due ", "when it is due (ISO-8601, e.g. 2026-09-03T10:00:00Z)") + .option("-p, --person ", "attach to a person (lands in person:)") + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) + .option("--idempotency-key ", "natural key to make retries safe") + .option("-m, --metadata ", "metadata key=value pairs (repeatable)") + .action(async (content: string, opts: AddOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const dueAt = requireIsoDate(opts.due ?? "", "--due", json); + const metadata = parseMetadata(opts.metadata); + if (opts.person && opts.container) { + return failUsage(json, "invalid_argument", "Pass either --person or --container , not both."); + } + const ctx = await getClient(); + const containerTag = opts.person ? undefined : resolveContainerTag(ctx.cfg, opts.container); + if (!opts.person && !containerTag) { + return failUsage( + json, + "container_required", + "A target is required. Pass --person , --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", + ); + } + const body: CreateReminderInput = { + content, + dueAt, + personSlug: opts.person, + containerTag, + idempotencyKey: opts.idempotencyKey, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + const reminder = await ctx.api.post("/v1/reminders", body); + if (json) return printJson(reminder); + printSuccess(`Reminder ${kleur.dim(reminder.id)} due ${formatWhen(reminder.dueAt)}`); + }); + + reminders + .command("complete ") + .description("mark a reminder as done") + .action(async (id: string, _opts: unknown, cmd: Command) => { + const json = rootJsonFlag(cmd); + const ctx = await getClient(); + const reminder = await ctx.api.post(`/v1/reminders/${encodeURIComponent(id)}/complete`); + if (json) return printJson(reminder); + printSuccess(`Completed ${kleur.dim(reminder.id)}: ${oneLine(reminder.content)}`); + }); +} diff --git a/src/commands/timeline.test.ts b/src/commands/timeline.test.ts new file mode 100644 index 0000000..1c4d53d --- /dev/null +++ b/src/commands/timeline.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { installHarness, jsonResponse, mockFetch, requestAt, run, runExpectingExit, TEST_BASE_URL } from "../test/harness.js"; + +const TIMELINE = { + items: [ + { id: "memory:m1", type: "memory", refId: "m1", occurredAt: "2026-09-02T20:00:00.000Z", title: "Met Bob about the Q4 plan", snippet: null, containerTag: "user:me", createdBy: { kind: "user", id: "u", label: null }, meta: { memoryType: "note" } }, + { id: "document:d1", type: "document", refId: "d1", occurredAt: "2026-09-01T10:00:00.000Z", title: "Sync with Jane", snippet: "Agenda: pricing", containerTag: "user:me", createdBy: null, meta: { provider: "google_calendar" } }, + ], + nextCursor: "cur2", + container: { tag: "user:me", containerType: "user", displayName: null }, + range: { from: null, to: null }, +}; + +describe("getmnemo timeline", () => { + const out = installHarness(); + + it("GETs /v1/timeline with every filter", async () => { + const fetchMock = mockFetch(jsonResponse(TIMELINE)); + await run([ + "--json", "timeline", "--container", "user:me", "--from", "2026-09-01T00:00:00Z", "--to", "2026-09-03T00:00:00Z", + "--types", "memory,document,memory", "--direction", "asc", "--limit", "20", "--cursor", "c1", + ]); + const req = requestAt(fetchMock); + expect(req.method).toBe("GET"); + expect(req.url).toBe( + `${TEST_BASE_URL}/v1/timeline?containerTag=user%3Ame&from=2026-09-01T00%3A00%3A00Z&to=2026-09-03T00%3A00%3A00Z&types=memory%2Cdocument&direction=asc&limit=20&cursor=c1`, + ); + expect(JSON.parse(out.stdout())).toMatchObject({ nextCursor: "cur2" }); + }); + + it("uses the config/env container and desc default; renders rows", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = mockFetch(jsonResponse(TIMELINE)); + await run(["timeline"]); + expect(requestAt(fetchMock).url).toBe(`${TEST_BASE_URL}/v1/timeline?containerTag=user%3Aenv&direction=desc&limit=50`); + expect(out.stdout()).toMatch(/2026-09-02 20:00Z.*memory.*Met Bob about the Q4 plan/); + expect(out.stdout()).toMatch(/document.*Sync with Jane.*Agenda: pricing/); + expect(out.stdout()).toMatch(/more: --cursor cur2/); + }); + + it("exits 2 without a container", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["timeline"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/A container is required/); + }); + + it("rejects unknown --types and --direction before any request", async () => { + const fetchMock = mockFetch(); + await runExpectingExit(["timeline", "--container", "user:me", "--types", "memory,photo"], 2); + await runExpectingExit(["timeline", "--container", "user:me", "--direction", "sideways"], 2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(out.stderr()).toMatch(/--types must be a comma-separated subset of: memory, reminder, document, event/); + expect(out.stderr()).toMatch(/--direction must be one of: desc, asc/); + }); + + it("prints an empty hint for an unknown container (API returns items: [])", async () => { + mockFetch(jsonResponse({ items: [], nextCursor: null, container: null, range: { from: null, to: null } })); + await run(["timeline", "--container", "user:ghost"]); + expect(out.stdout()).toMatch(/Nothing on the timeline for user:ghost/); + }); +}); diff --git a/src/commands/timeline.ts b/src/commands/timeline.ts new file mode 100644 index 0000000..3690a8c --- /dev/null +++ b/src/commands/timeline.ts @@ -0,0 +1,90 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient } from "../lib/client.js"; +import { CONTAINER_OPTION_DESC, CONTAINER_OPTION_FLAGS, requireContainerTag } from "../lib/container.js"; +import { + failUsage, + formatWhen, + oneLine, + parseIntFlag, + printInfo, + printJson, + printLine, + requireIsoDate, + requireOneOf, + rootJsonFlag, +} from "../lib/output.js"; +import { TIMELINE_ITEM_TYPES, type TimelineItem, type Timeline } from "../lib/personal-types.js"; + +const DIRECTIONS = ["desc", "asc"] as const; + +interface TimelineOpts { + container?: string; + from?: string; + to?: string; + types?: string; + direction?: string; + limit?: string; + cursor?: string; +} + +/** Validates a csv of item types (order preserved, duplicates dropped) or exit 2. */ +export function parseTypes(raw: string | undefined, json: boolean): string | undefined { + if (raw === undefined) return undefined; + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + const unique = [...new Set(parts)]; + if (unique.length === 0 || unique.some((t) => !(TIMELINE_ITEM_TYPES as readonly string[]).includes(t))) { + return failUsage(json, "invalid_argument", `--types must be a comma-separated subset of: ${TIMELINE_ITEM_TYPES.join(", ")}`); + } + return unique.join(","); +} + +const TYPE_GLYPH: Record = { + memory: "●", + reminder: "◷", + document: "▤", + event: "·", +}; + +function timelineLine(item: TimelineItem): string { + const glyph = TYPE_GLYPH[item.type]; + const by = item.createdBy?.label ?? item.createdBy?.kind; + const snippet = item.snippet && item.snippet !== item.title ? kleur.dim(` ${oneLine(item.snippet, 80)}`) : ""; + return `${kleur.dim(formatWhen(item.occurredAt))} ${glyph} ${item.type.padEnd(8)} ${oneLine(item.title, 100)}${by ? kleur.dim(` · ${by}`) : ""}${snippet}`; +} + +export function registerTimelineCommand(program: Command): void { + program + .command("timeline") + .description("chronological memories, reminders and documents for one container") + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) + .option("--from ", "only items at/after this time") + .option("--to ", "only items at/before this time") + .option("--types ", "subset of memory,reminder,document,event (default: memory,reminder,document)") + .option("--direction ", "desc | asc", "desc") + .option("-l, --limit ", "page size (1-100)", "50") + .option("-c, --cursor ", "pagination cursor") + .action(async (opts: TimelineOpts, cmd: Command) => { + const json = rootJsonFlag(cmd); + const limit = parseIntFlag(opts.limit, "--limit", json, { min: 1, max: 100, fallback: 50 }); + const direction = requireOneOf(opts.direction, "--direction", DIRECTIONS, json, "desc"); + const from = opts.from === undefined ? undefined : requireIsoDate(opts.from, "--from", json); + const to = opts.to === undefined ? undefined : requireIsoDate(opts.to, "--to", json); + const types = parseTypes(opts.types, json); + const ctx = await getClient(); + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); + const result = await ctx.api.get("/v1/timeline", { + containerTag, + from, + to, + types, + direction, + limit, + cursor: opts.cursor, + }); + if (json) return printJson(result); + if (result.items.length === 0) return printInfo(`Nothing on the timeline for ${containerTag}.`); + for (const item of result.items) printLine(timelineLine(item)); + if (result.nextCursor) printInfo(`more: --cursor ${result.nextCursor}`); + }); +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 4268ec0..7fa2c5c 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,4 +1,5 @@ import { Mnemo } from "getmnemo"; +import { PersonalApi } from "./personal-api.js"; import { readConfig, resolveApiKey, @@ -10,6 +11,8 @@ import { export interface ClientContext { client: Mnemo; + /** Transport for people/reminders/brief/timeline/meetings/merge. */ + api: PersonalApi; apiKey: string; workspaceId: string; baseUrl: string; @@ -51,7 +54,8 @@ export async function getClient(): Promise { baseUrl, defaultContainerTag: resolveContainerTag(cfg), }); - return { client, apiKey, workspaceId, baseUrl, cfg }; + const api = new PersonalApi({ apiKey, baseUrl }); + return { client, api, apiKey, workspaceId, baseUrl, cfg }; } export function parseMetadata(pairs: string[] | undefined): Record { diff --git a/src/lib/container.ts b/src/lib/container.ts new file mode 100644 index 0000000..01ae84b --- /dev/null +++ b/src/lib/container.ts @@ -0,0 +1,22 @@ +import { resolveContainerTag, type CliConfig } from "./config.js"; +import { failUsage } from "./output.js"; + +export const CONTAINER_OPTION_FLAGS = "-C, --container "; +export const CONTAINER_OPTION_DESC = + "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config"; + +/** + * Resolve a container tag (flag > GETMNEMO_CONTAINER > config) or exit 2. + * Every container-scoped route 400s without one, so fail before any request. + */ +export function requireContainerTag(cfg: CliConfig, flag: string | undefined, json: boolean): string { + const containerTag = resolveContainerTag(cfg, flag); + if (!containerTag) { + return failUsage( + json, + "container_required", + "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", + ); + } + return containerTag; +} diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts new file mode 100644 index 0000000..e0970e0 --- /dev/null +++ b/src/lib/output.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildQuery } from "./personal-api.js"; +import { formatWhen, oneLine, truncate } from "./output.js"; + +describe("output helpers", () => { + it("formatWhen keeps the zone verbatim and handles date-only / null", () => { + expect(formatWhen("2026-09-03T10:00:00.000Z")).toBe("2026-09-03 10:00Z"); + expect(formatWhen("2026-09-03T10:00:00+05:00")).toBe("2026-09-03 10:00+05:00"); + expect(formatWhen("2026-09-03T10:00")).toBe("2026-09-03 10:00"); + expect(formatWhen("2026-09-03")).toBe("2026-09-03"); + expect(formatWhen(null)).toBe("—"); + expect(formatWhen("not a date")).toBe("not a date"); + }); + + it("oneLine collapses whitespace and truncates", () => { + expect(oneLine(" hello\n world ")).toBe("hello world"); + expect(oneLine("a".repeat(120), 10)).toBe("a".repeat(9) + "…"); + expect(truncate("short")).toBe("short"); + }); + + it("buildQuery omits undefined and encodes reserved characters", () => { + expect(buildQuery({ a: "x:y", b: undefined, c: 3, d: false })).toBe("?a=x%3Ay&c=3&d=false"); + expect(buildQuery({})).toBe(""); + expect(buildQuery(undefined)).toBe(""); + }); +}); diff --git a/src/lib/output.ts b/src/lib/output.ts index 93fdfb4..7172843 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -4,8 +4,19 @@ export interface RootOpts { json?: boolean; } -export function rootJsonFlag(cmd: { parent?: { opts(): RootOpts } | null; opts(): RootOpts }): boolean { - return Boolean(cmd.opts().json) || Boolean(cmd.parent?.opts().json); +interface CommandLike { + parent?: CommandLike | null; + opts(): RootOpts; +} + +/** True when `--json` was given on this command or any ancestor (root flag). */ +export function rootJsonFlag(cmd: CommandLike): boolean { + let current: CommandLike | null | undefined = cmd; + while (current) { + if (current.opts().json) return true; + current = current.parent; + } + return false; } export function printJson(value: unknown): void { @@ -24,7 +35,92 @@ export function printInfo(msg: string): void { process.stdout.write(kleur.cyan("→ ") + msg + "\n"); } +export function printHeading(msg: string): void { + process.stdout.write(kleur.bold(msg) + "\n"); +} + +export function printLine(msg = ""): void { + process.stdout.write(msg + "\n"); +} + export function truncate(text: string, max = 80): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "…"; } + +/** + * `2026-09-03T10:00:00.000Z` → `2026-09-03 10:00Z`, + * `2026-09-03T10:00:00+05:00` → `2026-09-03 10:00+05:00`, `2026-09-03` → as is; + * null-safe. The zone is kept verbatim so a local-offset start is never + * mislabelled as UTC. + */ +export function formatWhen(iso: string | null | undefined): string { + if (!iso) return "—"; + const match = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.exec(iso); + if (!match) return iso; + const [, day, time, zone] = match; + if (!time) return day ?? iso; + return `${day} ${time}${zone ?? ""}`; +} + +/** Single-line first sentence of a memory/snippet. */ +export function oneLine(text: string, max = 100): string { + return truncate(text.replace(/\s+/g, " ").trim(), max); +} + +/** + * Usage failure: exit 2 with a stable machine-readable code under --json or + * a human message otherwise. Runs before any network request. + */ +export function failUsage(json: boolean, code: string, message: string): never { + if (json) { + printJson({ ok: false, error: code, message }); + } else { + printError(message); + } + return process.exit(2); +} + +/** Parse an integer CLI flag within [min, max] or exit 2. */ +export function parseIntFlag( + raw: string | undefined, + flag: string, + json: boolean, + bounds: { min: number; max: number; fallback: number }, +): number { + if (raw === undefined) return bounds.fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value < bounds.min || value > bounds.max || String(value) !== raw.trim()) { + return failUsage(json, "invalid_argument", `${flag} must be an integer between ${bounds.min} and ${bounds.max}`); + } + return value; +} + +/** Validate an ISO-8601 date/date-time flag (passed through verbatim) or exit 2. */ +export function requireIsoDate(raw: string, flag: string, json: boolean): string { + const trimmed = raw.trim(); + if (!trimmed || Number.isNaN(Date.parse(trimmed))) { + return failUsage(json, "invalid_argument", `${flag} must be an ISO-8601 date or date-time (e.g. 2026-09-03T10:00:00Z)`); + } + return trimmed; +} + +export function requireOneOf( + raw: string | undefined, + flag: string, + allowed: readonly T[], + json: boolean, + fallback: T, +): T { + if (raw === undefined) return fallback; + if ((allowed as readonly string[]).includes(raw)) return raw as T; + return failUsage(json, "invalid_argument", `${flag} must be one of: ${allowed.join(", ")}`); +} + +export function systemTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} diff --git a/src/lib/personal-api.test.ts b/src/lib/personal-api.test.ts new file mode 100644 index 0000000..80c03ec --- /dev/null +++ b/src/lib/personal-api.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { PersonalApi, PersonalApiError } from "./personal-api.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("PersonalApi transport", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("GET builds the query string, skips undefined values and sends auth headers", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ items: [] })); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid/" }); + + const result = await api.get<{ items: unknown[] }>("/v1/people", { + q: "jane", + limit: 20, + cursor: undefined, + includeArchived: true, + }); + + expect(result).toEqual({ items: [] }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.test.invalid/v1/people?q=jane&limit=20&includeArchived=true"); + expect(init.method).toBe("GET"); + const headers = init.headers as Record; + expect(headers.authorization).toBe("Bearer mk_1"); + expect(headers["user-agent"]).toMatch(/^getmnemo-cli\/\d+\.\d+\.\d+/); + expect(init.body).toBeUndefined(); + }); + + it("POST serialises the body as JSON with content-type", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "x" }, 201)); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + + await api.post("/v1/people", { displayName: "Jane", email: undefined }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("POST"); + expect((init.headers as Record)["content-type"]).toBe("application/json"); + expect(JSON.parse(String(init.body))).toEqual({ displayName: "Jane" }); + }); + + it("POST without a body sends no content-type and no body", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + + await api.post("/v1/reminders/abc/complete"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBeUndefined(); + expect((init.headers as Record)["content-type"]).toBeUndefined(); + }); + + it("maps an API error envelope to PersonalApiError with status + code", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse( + { statusCode: 503, error: "Service Unavailable", code: "FEATURE_DISABLED", message: "people is not enabled for this deployment." }, + 503, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + + const err = await api.get("/v1/people").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PersonalApiError); + const apiErr = err as PersonalApiError; + expect(apiErr.status).toBe(503); + expect(apiErr.code).toBe("FEATURE_DISABLED"); + expect(apiErr.message).toBe("people is not enabled for this deployment. (FEATURE_DISABLED)"); + }); + + it("joins array messages from class-validator 400s", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ statusCode: 400, message: ["displayName must be shorter", "property foo should not exist"], error: "Bad Request" }, 400), + ); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + + const err = (await api.post("/v1/people", {}).catch((e: unknown) => e)) as PersonalApiError; + expect(err.status).toBe(400); + expect(err.code).toBeNull(); + expect(err.message).toBe("displayName must be shorter; property foo should not exist"); + }); + + it("falls back to HTTP status text when the error body is not JSON", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("nope", { status: 502, statusText: "Bad Gateway" })); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + + const err = (await api.get("/v1/brief").catch((e: unknown) => e)) as PersonalApiError; + expect(err.status).toBe(502); + expect(err.message).toBe("HTTP 502 Bad Gateway"); + }); + + it("returns undefined for an empty 2xx body", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid" }); + await expect(api.get("/v1/x")).resolves.toBeUndefined(); + }); + + it("surfaces a timeout as PersonalApiError with status 0", async () => { + const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + const e = new Error("aborted"); + e.name = "AbortError"; + reject(e); + }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + const api = new PersonalApi({ apiKey: "mk_1", baseUrl: "https://api.test.invalid", timeoutMs: 5 }); + const err = (await api.get("/v1/brief").catch((e: unknown) => e)) as PersonalApiError; + expect(err).toBeInstanceOf(PersonalApiError); + expect(err.status).toBe(0); + expect(err.message).toMatch(/timed out after 5ms/); + }); +}); diff --git a/src/lib/personal-api.ts b/src/lib/personal-api.ts new file mode 100644 index 0000000..60dc318 --- /dev/null +++ b/src/lib/personal-api.ts @@ -0,0 +1,148 @@ +import { CLI_VERSION } from "./version.js"; + +/** + * Thin typed REST transport for the personal-memory surfaces (people, + * reminders, brief, timeline, meetings, merge). The published `getmnemo` SDK + * (0.5.1) does not expose these resources yet, so — like the MCP server — the + * CLI owns its own client for them. Same auth plane as the SDK: + * `Authorization: Bearer`. Every call is container/tenant-scoped by the + * caller (query or body); this layer never invents a fallback container. + * + * Wire shapes are identical to getmnemo 0.6.0's `src/personal/*` resources, + * so the swap is mechanical once 0.6.0 is on npm: + * api.get('/v1/people', q) → client.people.list(q) + * api.get('/v1/people/{slug}') → client.people.get(slug) + * api.post('/v1/people', body) → client.people.create(body) + * api.get('/v1/reminders', q) → client.reminders.list(q) + * api.get('/v1/reminders/upcoming', q) → client.reminders.upcoming(q) + * api.post('/v1/reminders', body) → client.reminders.create(body) + * api.post('/v1/reminders/{id}/complete') → client.reminders.complete(id) + * api.get('/v1/brief', q) → client.brief.get(q) + * api.get('/v1/timeline', q) → client.timeline.get(q) + * api.get('/v1/meetings/upcoming', q) → client.meetings.upcoming(q) + * api.get('/v1/meetings/{id}/brief', q) → client.meetings.brief(id, q) + * api.post('/v1/memories/merge', body) → client.memories.merge(body) + */ + +export type QueryValue = string | number | boolean | undefined; +export type QueryParams = Record; + +export interface PersonalApiConfig { + apiKey: string; + baseUrl: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class PersonalApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null, + readonly body: unknown, + ) { + super(message); + this.name = "PersonalApiError"; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +/** Builds `?a=b&c=d` from defined values only (undefined keys are omitted). */ +export function buildQuery(params: QueryParams | undefined): string { + if (!params) return ""; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + search.set(key, String(value)); + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ""; +} + +function errorFromBody(status: number, statusText: string, parsed: unknown): PersonalApiError { + if (isRecord(parsed)) { + const rawMessage = parsed.message; + const code = typeof parsed.code === "string" ? parsed.code : null; + const message = Array.isArray(rawMessage) + ? rawMessage.map((m) => String(m)).join("; ") + : typeof rawMessage === "string" + ? rawMessage + : null; + if (message) { + return new PersonalApiError(code ? `${message} (${code})` : message, status, code, parsed); + } + } + return new PersonalApiError(`HTTP ${status} ${statusText}`.trim(), status, null, parsed); +} + +export class PersonalApi { + readonly #baseUrl: string; + readonly #apiKey: string; + readonly #timeoutMs: number; + readonly #fetch: typeof fetch; + + constructor(cfg: PersonalApiConfig) { + this.#baseUrl = cfg.baseUrl.replace(/\/+$/, ""); + this.#apiKey = cfg.apiKey; + this.#timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; + // Resolve lazily so tests that stub the global fetch after construction + // still hit the mock. + this.#fetch = cfg.fetchImpl ?? ((input, init) => fetch(input, init)); + } + + get(path: string, query?: QueryParams): Promise { + return this.#request("GET", path, query); + } + + post(path: string, body?: unknown, query?: QueryParams): Promise { + return this.#request("POST", path, query, body); + } + + async #request(method: string, path: string, query?: QueryParams, body?: unknown): Promise { + const url = `${this.#baseUrl}${path}${buildQuery(query)}`; + const headers: Record = { + authorization: `Bearer ${this.#apiKey}`, + "user-agent": `getmnemo-cli/${CLI_VERSION}`, + accept: "application/json", + }; + const serializedBody = body === undefined ? undefined : JSON.stringify(body); + if (serializedBody !== undefined) headers["content-type"] = "application/json"; + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), this.#timeoutMs); + try { + const res = await this.#fetch(url, { + method, + headers, + body: serializedBody, + signal: ctrl.signal, + }); + const text = await res.text(); + const parsed: unknown = text ? safeJson(text) : undefined; + if (!res.ok) throw errorFromBody(res.status, res.statusText, parsed); + return parsed as T; + } catch (err) { + if (err instanceof PersonalApiError) throw err; + if (err instanceof Error && err.name === "AbortError") { + throw new PersonalApiError(`Request timed out after ${this.#timeoutMs}ms`, 0, "TIMEOUT", null); + } + const message = err instanceof Error ? err.message : String(err); + throw new PersonalApiError(`Network error: ${message}`, 0, "NETWORK", null); + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/lib/personal-types.ts b/src/lib/personal-types.ts new file mode 100644 index 0000000..841bda3 --- /dev/null +++ b/src/lib/personal-types.ts @@ -0,0 +1,229 @@ +/** + * Response/request shapes for the personal-memory endpoints, mirrored from + * the API's public OpenAPI (api/src/public-api/dto/{people,reminders,brief, + * timeline,meetings,memories}.dto.ts). Names match the `getmnemo` SDK's + * `src/personal/types.ts` (0.6.0) so the CLI can switch to the SDK resources + * once that release is on npm. Only the fields the CLI renders or sends are + * typed; `--json` passes the raw payload through untouched. + */ + +export type ProvenanceKind = "api_key" | "mcp" | "user" | "connector" | "inbound" | "system"; + +export interface MemoryProvenance { + kind: ProvenanceKind; + id: string | null; + label: string | null; +} + +export interface MemoryRecord { + id: string; + content: string; + memoryType: string; + metadata: Record | null; + createdAt: string; + updatedAt: string; + dueAt: string | null; + createdBy: MemoryProvenance | null; + container?: { tag?: string } | null; +} + +export interface PersonImportantDate { + label: string; + date: string; + recurring: boolean; +} + +export interface CreatePersonInput { + displayName: string; + slug?: string; + relationship?: string; + email?: string; + phone?: string; + company?: string; + notes?: string; + importantDates?: PersonImportantDate[]; + aliases?: string[]; +} + +export interface Person { + slug: string; + tag: string; + containerId: string; + displayName: string; + relationship: string | null; + email: string | null; + phone: string | null; + company: string | null; + notes: string | null; + importantDates: PersonImportantDate[]; + aliases: string[]; + archivedAt: string | null; + memoryCount: number; + openReminderCount: number; + nextReminderAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PaginatedPeople { + items: Person[]; + nextCursor: string | null; + total: number; +} + +export interface Reminder extends MemoryRecord { + completedAt: string | null; + person: { slug: string; displayName: string } | null; +} + +export interface CreateReminderInput { + content: string; + dueAt: string; + personSlug?: string; + containerTag?: string; + idempotencyKey?: string; + metadata?: Record; +} + +export interface PaginatedReminders { + items: Reminder[]; + nextCursor: string | null; + total: number; +} + +export interface ImportantDate { + personSlug: string; + displayName: string; + label: string; + date: string; + daysUntil: number; + recurring: boolean; +} + +export interface UpcomingReminders { + overdue: Reminder[]; + dueToday: Reminder[]; + upcoming: Reminder[]; + importantDates: ImportantDate[]; + generatedAt: string; + timezone: string; +} + +export interface AnswerCitation { + type: string; + score: number; + content: string; + sourceId?: string; + title?: string; + url?: string; +} + +export interface BriefFollowUps { + answer: string; + citations: AnswerCitation[]; + abstained: boolean; + cached: boolean; +} + +export interface MeetingAttendee { + email: string | null; + name: string | null; + responseStatus: string | null; + self: boolean; + person: { slug: string; displayName: string } | null; +} + +export interface Meeting { + documentId: string; + eventId: string | null; + title: string; + start: string | null; + end: string | null; + isAllDay: boolean; + status: string | null; + htmlLink: string | null; + location: string | null; + organizer: { email: string | null; name: string | null } | null; + attendees: MeetingAttendee[]; + containerTag: string; + connectionId: string | null; + attendeeSource: "metadata" | "contentText" | "none"; +} + +export interface MeetingConnection { + id: string; + containerTag: string; + status: string; + lastSyncAt: string | null; +} + +export interface UpcomingMeetings { + items: Meeting[]; + nextCursor: string | null; + connections: MeetingConnection[]; +} + +export interface MeetingBrief extends Meeting { + brief: BriefFollowUps | null; + people: Array<{ + slug: string; + displayName: string; + relationship: string | null; + openReminders: Reminder[]; + recentMemories: MemoryRecord[]; + }>; + previousMeetings: Array<{ documentId: string; title: string; start: string | null }>; + generatedAt: string; +} + +export interface DailyBrief { + date: string; + timezone: string; + generatedAt: string; + scope: { kind: "workspace" | "container"; containerTag: string | null }; + reminders: { overdue: Reminder[]; dueToday: Reminder[]; upcoming: Reminder[] } | null; + importantDates: ImportantDate[] | null; + recentMemories: MemoryRecord[] | null; + counts: { memoriesLast24h: number; documentsLast24h: number } | null; + followUps: BriefFollowUps | null; + meetings: Meeting[] | null; +} + +export type TimelineItemType = "memory" | "reminder" | "document" | "event"; +export const TIMELINE_ITEM_TYPES: readonly TimelineItemType[] = ["memory", "reminder", "document", "event"]; + +export interface TimelineItem { + id: string; + type: TimelineItemType; + refId: string; + occurredAt: string; + title: string; + snippet: string | null; + containerTag: string; + createdBy: MemoryProvenance | null; + meta: Record; +} + +export interface Timeline { + items: TimelineItem[]; + nextCursor: string | null; + container: { tag: string; containerType: string; displayName: string | null } | null; + range: { from: string | null; to: string | null }; +} + +export interface MergeMemoriesInput { + containerTag: string; + ids: string[]; + into?: string; + content?: string; + memoryType?: string; + metadata?: Record; + mergeKey?: string; +} + +export interface MergeMemoriesResponse { + memory: MemoryRecord; + mergedFromIds: string[]; + deletedIds: string[]; + replayed: boolean; +} diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 0000000..9c6c22f --- /dev/null +++ b/src/lib/version.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { CLI_VERSION } from "./version.js"; + +describe("CLI_VERSION", () => { + it("matches package.json so --version can never drift again", () => { + const pkg = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version: string }; + expect(CLI_VERSION).toBe(pkg.version); + expect(CLI_VERSION).toBe("0.3.0"); + }); +}); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..408a12f --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,22 @@ +import { createRequire } from "node:module"; + +/** + * Single source of truth for the CLI version: read from package.json at + * runtime (works from both `dist/` and `src/` because both sit one level + * below the package root). A hardcoded const drifted before (0.2.0 vs 0.2.1). + */ +function readPackageVersion(): string { + try { + const require = createRequire(import.meta.url); + const pkg: unknown = require("../../package.json"); + if (pkg && typeof pkg === "object" && "version" in pkg) { + const version = (pkg as { version: unknown }).version; + if (typeof version === "string" && version.length > 0) return version; + } + } catch { + // fall through + } + return "0.0.0-unknown"; +} + +export const CLI_VERSION: string = readPackageVersion(); diff --git a/src/test/harness.ts b/src/test/harness.ts new file mode 100644 index 0000000..db5fbaf --- /dev/null +++ b/src/test/harness.ts @@ -0,0 +1,111 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, vi } from "vitest"; +import { buildCli } from "../cli.js"; + +/** + * Shared vitest harness for command specs: captures stdout/stderr, points + * HOME at a nonexistent dir (so a developer's real ~/.getmnemo/config.json + * cannot leak into assertions), stubs auth env, and mocks the global fetch + * transport so every spec asserts the exact wire request. + */ +export interface Captured { + readonly stdout: () => string; + readonly stderr: () => string; +} + +export const TEST_BASE_URL = "https://api.test.invalid"; + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export function installHarness(): Captured { + let stdout = ""; + let stderr = ""; + let writeStdout: typeof process.stdout.write; + let writeStderr: typeof process.stderr.write; + + beforeEach(() => { + stdout = ""; + stderr = ""; + writeStdout = process.stdout.write.bind(process.stdout); + writeStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as typeof process.stderr.write; + 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", TEST_BASE_URL); + vi.stubEnv("GETMNEMO_CONTAINER", undefined); + }); + + afterEach(() => { + process.stdout.write = writeStdout; + process.stderr.write = writeStderr; + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + return { stdout: () => stdout, stderr: () => stderr }; +} + +export type FetchMock = ReturnType; + +export function mockFetch(...responses: Response[]): FetchMock { + const fetchMock = vi.fn(); + for (const res of responses) fetchMock.mockResolvedValueOnce(res); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +export interface CapturedRequest { + readonly url: string; + readonly method: string; + readonly headers: Record; + readonly body: unknown; +} + +export function requestAt(fetchMock: FetchMock, index = 0): CapturedRequest { + const call = fetchMock.mock.calls[index]; + if (!call) throw new Error(`fetch call #${index} was never made`); + const init = (call[1] ?? {}) as RequestInit; + const rawHeaders = init.headers; + const headers: Record = {}; + if (rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)) { + for (const [k, v] of Object.entries(rawHeaders as Record)) { + headers[k.toLowerCase()] = v; + } + } + const body = typeof init.body === "string" ? (JSON.parse(init.body) as unknown) : undefined; + return { url: String(call[0]), method: init.method ?? "GET", headers, body }; +} + +/** Mocks process.exit to throw so the spec can assert the code without dying. */ +export function mockExit(): ReturnType { + return vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); +} + +export async function run(argv: string[]): Promise { + const program = buildCli(); + program.exitOverride(); + await program.parseAsync(["node", "getmnemo", ...argv]); +} + +export async function runExpectingExit(argv: string[], code: number): Promise { + const exitSpy = mockExit(); + await expect(run(argv)).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(code); +} diff --git a/tsconfig.json b/tsconfig.json index 9a81771..3bdeb8a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] + "exclude": ["dist", "node_modules", "src/**/*.test.ts", "src/test/**"] }