diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index c03b6ce695..45b542f701 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -42,6 +42,7 @@ gittensory-mcp profile list gittensory-mcp profile create work gittensory-mcp profile switch work gittensory-mcp cache status +gittensory-mcp cache list gittensory-mcp cache clear gittensory-mcp init-client --print codex gittensory-mcp init-client --print claude @@ -245,3 +246,5 @@ The cache excludes source contents and local paths, is bounded, and can be remov ```sh gittensory-mcp cache clear ``` + +`gittensory-mcp cache list` shows the cached entries (newest first) with the login, when each was cached, and its API/package version and size — never the cached payload or the auth-cache key. `gittensory-mcp cache status` reports the aggregate entry count. diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index cab349031e..555ac156b7 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1483,6 +1483,13 @@ function runCacheCli(args) { else process.stdout.write(`Decision-pack cache: ${payload.entries} entr${payload.entries === 1 ? "y" : "ies"}.\n`); return; } + if (subcommand === "list" || subcommand === "ls") { + const payload = listDecisionPackCache(); + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else if (payload.count === 0) process.stdout.write("Decision-pack cache is empty.\n"); + else for (const entry of payload.entries) process.stdout.write(`- ${entry.login ?? "unknown"} (cached ${entry.cachedAt ?? "unknown"}, ${entry.bytes} bytes)\n`); + return; + } throw new Error(`Unknown cache command: ${subcommand}`); } @@ -1778,7 +1785,7 @@ function printHelp() { gittensory-mcp profile list|create|switch|remove [name] [--json] gittensory-mcp changelog [--json] gittensory-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] - gittensory-mcp cache status|clear [--json] + gittensory-mcp cache status|list|clear [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] gittensory-mcp decision-pack --login [--json] gittensory-mcp repo-decision --login --repo owner/repo [--json] @@ -1805,6 +1812,7 @@ function printHelp() { function printCacheHelp() { process.stdout.write(`Usage: gittensory-mcp cache status [--json] + gittensory-mcp cache list [--json] gittensory-mcp cache clear [--json] Decision-pack cache entries are local-only stale fallbacks for temporary API/network outages. @@ -2979,6 +2987,34 @@ function inspectDecisionPackCache() { }; } +// Per-entry view of the offline decision-pack cache, newest first. Surfaces only safe metadata +// (login, when it was cached, the API/package version, size) — never the auth-cache key (a token +// hash) or the cached payload — so it stays consistent with the cache's local-only redaction. +function listDecisionPackCache() { + const files = decisionPackCacheFiles().sort((left, right) => right.mtimeMs - left.mtimeMs); + const entries = files.map((file) => { + try { + const entry = JSON.parse(readFileSync(file.path, "utf8")); + return { + login: typeof entry.login === "string" ? entry.login : null, + cachedAt: typeof entry.cachedAt === "string" ? entry.cachedAt : null, + apiVersion: typeof entry.apiVersion === "string" ? entry.apiVersion : null, + packageVersion: typeof entry.packageVersion === "string" ? entry.packageVersion : null, + bytes: file.size, + }; + } catch { + return { login: null, cachedAt: null, apiVersion: null, packageVersion: null, bytes: file.size, corrupt: true }; + } + }); + return { + status: "ok", + count: entries.length, + maxEntries: decisionPackCacheMaxEntries, + clearCommand: "gittensory-mcp cache clear", + entries, + }; +} + function findExecutable(name) { for (const directory of String(process.env.PATH ?? "").split(delimiter).filter(Boolean)) { const candidate = join(directory, name); diff --git a/test/unit/mcp-cli-packets.test.ts b/test/unit/mcp-cli-packets.test.ts index d3be27eb50..b252ba881a 100644 --- a/test/unit/mcp-cli-packets.test.ts +++ b/test/unit/mcp-cli-packets.test.ts @@ -98,6 +98,39 @@ describe("gittensory-mcp CLI — packets", () => { expect(cacheStatus.entries).toBe(0); }); + it("lists cached decision packs with safe metadata only", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_API_TIMEOUT_MS: "1000", + }; + + const empty = JSON.parse(run(["cache", "list", "--json"], env)) as { count: number; entries: unknown[] }; + expect(empty).toMatchObject({ count: 0, entries: [] }); + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + const listed = JSON.parse(run(["cache", "list", "--json"], env)) as { + count: number; + entries: Array<{ login: string; cachedAt: string; apiVersion: string; packageVersion: string; bytes: number }>; + }; + expect(listed.count).toBe(1); + const [first] = listed.entries; + expect(first).toMatchObject({ login: "jsonbored", apiVersion: "0.1.0" }); + expect(first?.cachedAt).toEqual(expect.any(String)); + expect(first?.bytes).toBeGreaterThan(0); + + // Never leaks the token or the auth-cache key (a token hash). + const serialized = JSON.stringify(listed); + expect(serialized).not.toContain("session-token"); + expect(serialized).not.toMatch(/authCacheKey/); + + const human = run(["cache", "list"], env); + expect(human).toContain("jsonbored"); + }); + it("does not use stale decision-pack cache created by a different local token", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const fixtureOptions: { decisionPackStatus?: number } = {};