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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/gittensory-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
38 changes: 37 additions & 1 deletion packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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 <github-login> [--json]
gittensory-mcp repo-decision --login <github-login> --repo owner/repo [--json]
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions test/unit/mcp-cli-packets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand Down