diff --git a/CHANGELOG.md b/CHANGELOG.md index f70c6db..bc6099a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,27 @@ This log starts at v1.6.5 (the reliability-pillar repositioning). Earlier histor ## [Unreleased] -_Nothing landed since [1.6.18]._ +_Nothing landed since [1.6.19]._ + +## [1.6.19] — 2026-06-28 + +A growth feature plus a thorough hardening pass. A full component audit found and fixed a batch of edge-case bugs (mostly in the analytics/reporting layer and the proxy's output caps) before more users hit them. + +### Added +- **One-time GitHub-star nudge.** After `sipcode proxy --stats` shows real savings, a single line points to the repo, shown once per machine ever (gated on a `~/.sipcode/.star-nudge` marker). Never in `--json`, never when there are no rewrites, never blocks, zero network calls. +- README gained live GitHub-stars and npm-downloads social-proof badges. + +### Fixed +- **Proxy output caps no longer hide a command's failure.** Every cap (`tsc`, `git`, `find`, `ls`, `grep`, `npm`) used `cmd | head`, which returns exit 0 even when the command failed — so a failing `tsc --noEmit` could look like a clean build to Claude. All caps now use `set -o pipefail; cmd | awk 'NR<=N'`, which caps the output while preserving the command's real exit code (awk reads the whole stream, so there is no early-close SIGPIPE false-failure either). +- **`--here` now works for any project path.** It previously matched only paths without special characters; a folder like `C:\Projects\just research` (or any path with a dot, space, or parenthesis) silently found nothing. It now matches Claude Code's exact directory encoding. +- **`today`, `forecast`, and `trend` no longer crash** on `--agent auto` (which the help advertised) or a mistyped agent name. +- **Opt-out flags are honored.** `init --no-proxy` / `--no-marker` / `--no-verify-mcp` / `--no-claude-md` and `receipt --no-share` were silently ignored. +- **`cat` / `grep` rewriters corrected.** The proxy no longer rewrites bash's `type` builtin (which is not a file reader), and `grep -rn` keeps the matching lines and numbers instead of collapsing to per-file counts. +- **Empty / in-flight sessions are skipped consistently.** `impact`, `why`, and the `audit_latest_session` MCP tool no longer build an all-zero report from an empty latest session. +- Docs: `sipcode receipt` produces a PNG, not a "PDF"; a sample output file updated to opus 4.8. + +### Internal +- Test count: 1,373 → 1,388. Full suite green; privacy and no-shell-args guards green. Findings verified by a 3-part component audit. ## [1.6.18] — 2026-06-25 @@ -237,7 +257,8 @@ This release rolls v1.6.9's B3 work (bumped but never published to npm) together --- -[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.18...HEAD +[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.19...HEAD +[1.6.19]: https://github.com/Anuj7411/sipcode/compare/v1.6.18...v1.6.19 [1.6.18]: https://github.com/Anuj7411/sipcode/compare/v1.6.17...v1.6.18 [1.6.17]: https://github.com/Anuj7411/sipcode/compare/v1.6.16...v1.6.17 [1.6.16]: https://github.com/Anuj7411/sipcode/compare/v1.6.15...v1.6.16 diff --git a/README.md b/README.md index 2032237..fc32542 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@

npm + GitHub stars + npm downloads per month MIT licensed - 1373 tests passing + 1388 tests passing zero network calls

@@ -177,7 +179,7 @@ Verify it installed: sipcode --version ``` -You should see `1.6.18` or higher. +You should see `1.6.19` or higher. ### Step 3. Run `sipcode init` to wire it into Claude Code @@ -323,11 +325,11 @@ npm uninstall -g sipcode | `sipcode why` | Per-session forensics: where your tokens died | | `sipcode impact` | A/B compare your spend before vs after Sipcode | | `sipcode score` | Audit your repo for AI-friendliness (0-100, tiered badge) | -| `sipcode receipt` | Generate a shareable PDF receipt of a session | +| `sipcode receipt` | Generate a shareable PNG receipt of a session | Run any of them with `--help` for full options. -**Tip: which session do these report on?** `sipcode why`, `today`, and `stats` look at your most recent session across **all** projects by default, not the folder you happen to be standing in. So if you run `sipcode why` inside project A but project B had the most recent activity, you will see project B. To scope a command to the project you are currently in, add `--here` (for example, `sipcode why --here`). Use `sipcode why --list` to see every session and pick one with `--session `. +**Tip: which session do these report on?** `sipcode why` and `sipcode stats` look at your most recent session across **all** projects by default, not the folder you happen to be standing in. So if you run `sipcode why` inside project A but project B had the most recent activity, you will see project B. To scope either to the project you are currently in, add `--here` (for example, `sipcode why --here`). Use `sipcode why --list` to see every session and pick one with `--session `. (`sipcode today` always summarizes all of today's sessions and has no `--here`.) --- diff --git a/docs/screenshots/sipcode-why-sample.txt b/docs/screenshots/sipcode-why-sample.txt index 3dac7c6..3e5aca5 100644 --- a/docs/screenshots/sipcode-why-sample.txt +++ b/docs/screenshots/sipcode-why-sample.txt @@ -1,6 +1,6 @@ (showing latest session 84bbf968 from C--Projects-Sipcode; pass --here to scope to this directory) -sipcode why · session 84bbf968 · 60h 26m · claude-opus-4-7 +sipcode why · session 84bbf968 · 60h 26m · claude-opus-4-8 project: C--Projects-Sipcode you burned 225,576,407 tokens. 936,947 were code output. the other 224,639,460 were exploration, re-reads, and idle context. diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt index 7414fae..e06f6b2 100644 --- a/docs/site/public/llms-full.txt +++ b/docs/site/public/llms-full.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,373 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,388 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -140,7 +140,7 @@ Verify: `node --version` should show v18.0.0 or higher. npm i -g sipcode ``` -Verify: `sipcode --version` should show 1.6.18 or higher. +Verify: `sipcode --version` should show 1.6.19 or higher. ### Step 3. Run `sipcode init` @@ -221,7 +221,7 @@ npm uninstall -g sipcode | `sipcode impact` | Before vs after Sipcode comparison | | `sipcode why` | Per-session forensics | | `sipcode score` | Audit a repo for AI-friendliness | -| `sipcode receipt` | Generate a shareable PDF receipt of a session | +| `sipcode receipt` | Generate a shareable PNG receipt of a session | ## Requirements diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt index 0cc0abf..b21d2e8 100644 --- a/docs/site/public/llms.txt +++ b/docs/site/public/llms.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,373 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,388 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -53,8 +53,8 @@ Sipcode ships an MCP server exposing 15 tools so Claude Code can introspect its ## Source code -- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,373 tests -- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.18 +- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,388 tests +- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.19 - [Privacy test](https://github.com/Anuj7411/sipcode/blob/main/src): build-blocking check that no network modules are imported in `src/` ## License diff --git a/docs/site/src/components/Footer.astro b/docs/site/src/components/Footer.astro index b171d0e..f28f86b 100644 --- a/docs/site/src/components/Footer.astro +++ b/docs/site/src/components/Footer.astro @@ -25,11 +25,11 @@
- v1.6.18 + v1.6.19 - 1,373 tests passing + 1,388 tests passing
@@ -19,7 +19,7 @@ const TEST_COUNT = 1373;
- v1.6.18 · MIT licensed + v1.6.19 · MIT licensed {TEST_COUNT.toLocaleString()} tests passing
diff --git a/package-lock.json b/package-lock.json index 1780a97..c99d67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 53d2ddd..5f2fa5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "mcpName": "io.github.Anuj7411/sipcode", "description": "Sip your tokens, don't gulp them. Keep Claude Code's context clean: drift detection, re-read dedup, integrity scoring, AST-aware reads, and 15 MCP tools for Claude Desktop.", "keywords": [ diff --git a/server.json b/server.json index 817a7d3..aabc0c7 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/Anuj7411/sipcode", "source": "github" }, - "version": "1.6.18", + "version": "1.6.19", "packages": [ { "registryType": "npm", "identifier": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/cli.ts b/src/cli.ts index 912faa7..e8b2a6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,17 @@ program .action(async (opts) => { const { runInit } = await import("./commands/init.js"); const { homedir } = await import("node:os"); - const r = await runInit(opts, { homeDir: homedir() }); + // Commander stores --no-X as opts.x=false; the runner reads opts.noX. + const r = await runInit( + { + ...opts, + noClaudeMd: opts.claudeMd === false, + noProxy: opts.proxy === false, + noMarker: opts.marker === false, + noVerifyMcp: opts.verifyMcp === false, + }, + { homeDir: homedir() }, + ); if (r?.exitCode) process.exit(r.exitCode); }); @@ -83,7 +93,12 @@ program .option("--agent ", "which agent to source transcripts from: claude-code | cursor | auto") .action(async (sessionId, opts) => { const { runReceipt } = await import("./commands/receipt.js"); - const result = await runReceipt({ ...opts, session: sessionId }); + // Commander stores --no-share as opts.share=false; runner reads opts.noShare. + const result = await runReceipt({ + ...opts, + session: sessionId, + noShare: opts.share === false, + }); if (result?.exitCode) process.exit(result.exitCode); }); diff --git a/src/commands/forecast.ts b/src/commands/forecast.ts index e3929c5..ed94ade 100644 --- a/src/commands/forecast.ts +++ b/src/commands/forecast.ts @@ -7,8 +7,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { runForecast, type ForecastSession } from "../modules/forecast/runForecast.js"; @@ -43,7 +43,21 @@ export async function runForecastCmd( const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n")); const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n")); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } const now = clock.now(); const pricing = loadPricingForDate(now); diff --git a/src/commands/impact.ts b/src/commands/impact.ts index 6773d5a..1997f25 100644 --- a/src/commands/impact.ts +++ b/src/commands/impact.ts @@ -17,7 +17,10 @@ import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { resolveAgentFromOpts } from "../modules/agents/cli.js"; import { resolveProjectsDir } from "../modules/transcript/discover.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; @@ -118,6 +121,7 @@ export async function runImpactCommand( if (!parseResult.ok) continue; const parsed = parseResult.value; const totals = analyzeTokens(parsed, pricing); + if (isEmptySession(totals)) continue; const dups = analyzeDuplicateReads(parsed); const idle = analyzeIdleContext(parsed); aggregated.push( diff --git a/src/commands/proxy.ts b/src/commands/proxy.ts index 0d9c3f7..426e0c2 100644 --- a/src/commands/proxy.ts +++ b/src/commands/proxy.ts @@ -28,6 +28,8 @@ import { import { generateProxyHookScript } from "../modules/proxy/proxyHookScript.js"; import { readReport } from "../modules/proxy/stats-store.js"; import { renderProxyReport } from "../modules/proxy/format-terminal.js"; +import { shouldShowStarNudge } from "../lib/starNudge.js"; +import { MESSAGES } from "../lib/messages.js"; export interface ProxyOptions { install?: boolean; @@ -88,7 +90,26 @@ export async function runProxy( // --stats — if (opts.stats) { const report = await readReport(statsDir); - stdout(opts.json ? JSON.stringify(report, null, 2) : renderProxyReport(report)); + if (opts.json) { + stdout(JSON.stringify(report, null, 2)); + return { exitCode: 0 }; + } + stdout(renderProxyReport(report)); + // One-time star nudge, only at a real value moment (rewrites happened). + // Marker lives under ~/.sipcode so it shows once per machine, ever. + if (report.totalInvocations > 0) { + const shown = await shouldShowStarNudge( + { + hasMarker: async (p) => (await readFile(p)) !== undefined, + writeMarker: writeFile, + }, + homeDir, + ); + if (shown) { + stdout(""); + stdout(MESSAGES.starNudge()); + } + } return { exitCode: 0 }; } diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 72adc0f..0da626c 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -14,7 +14,10 @@ import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { MESSAGES } from "../lib/messages.js"; import { resolveAgentFromOpts } from "../modules/agents/cli.js"; -import { resolveProjectsDir } from "../modules/transcript/discover.js"; +import { + resolveProjectsDir, + cwdToProjectHash, +} from "../modules/transcript/discover.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; @@ -151,7 +154,7 @@ export async function runStats( // --here filter: scope to the cwd's projectHash. if (opts.here) { - const cwdHash = cwd.replace(/:/g, "-").replace(/[\\/]/g, "-"); + const cwdHash = cwdToProjectHash(cwd); metas = metas.filter( (m) => m.projectHash === cwdHash || cwdHash.endsWith(m.projectHash), ); diff --git a/src/commands/today.ts b/src/commands/today.ts index 1779f94..d7ea977 100644 --- a/src/commands/today.ts +++ b/src/commands/today.ts @@ -9,8 +9,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; @@ -46,7 +46,21 @@ export async function runTodayCmd( const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n")); const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n")); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } const now = clock.now(); const pricing = loadPricingForDate(now); diff --git a/src/commands/trend.ts b/src/commands/trend.ts index 135fed0..465f0a3 100644 --- a/src/commands/trend.ts +++ b/src/commands/trend.ts @@ -13,8 +13,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; @@ -83,7 +83,21 @@ export async function runTrend( const sinceIso = since.toISOString().slice(0, 10); const untilIso = until.toISOString().slice(0, 10); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } // Pricing — keyed off the window upper bound. const pricing = loadPricingForDate(until); diff --git a/src/commands/why.ts b/src/commands/why.ts index 5e16abe..f333fcb 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -17,7 +17,10 @@ import { type SessionMeta, } from "../modules/transcript/discover.js"; import { parseTranscriptVerbose } from "../modules/transcript/parse.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { analyzeTopExpensive } from "../modules/transcript/analyzers/topExpensive.js"; @@ -51,6 +54,34 @@ export interface WhyResult { exitCode: 0 | 1; } +/** + * Return the newest session that actually has work in it (skips empty / + * in-flight transcripts that would render an all-zero report). Sessions are + * newest-first; falls back to the newest if every one is empty so we always + * show something. Parses lazily and stops at the first non-empty hit, so the + * common case (latest session is real) parses exactly one transcript. + */ +async function pickLatestNonEmpty( + fs: FileSystem, + sessions: SessionMeta[], + now: Date, +): Promise { + for (const m of sessions) { + let contents: string; + try { + contents = await fs.readFile(m.filePath); + } catch { + continue; + } + const { session } = parseTranscriptVerbose(contents); + const date = session.startedAt ? new Date(session.startedAt) : now; + if (!isEmptySession(analyzeTokens(session, loadPricingForDate(date)))) { + return m; + } + } + return sessions[0]; +} + export async function runWhy( opts: WhyOptions, deps: WhyDeps = {}, @@ -120,7 +151,10 @@ export async function runWhy( return { exitCode: 1 }; } } else { - chosen = sessions[0]; + // Auto-pick the newest NON-empty session. An empty/in-flight latest + // session would otherwise render an all-zero report (drift guards the same + // way). Falls back to the newest if every session is empty. + chosen = await pickLatestNonEmpty(fs, sessions, clock.now()); } if (!chosen) { stderr(MESSAGES.noSessionsFound(projectsDir)); diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 8a105fc..eadf398 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -272,6 +272,12 @@ export const MESSAGES = { `next: npx sipcode stats --group-by project`, ].join("\n"), + starNudge: () => + [ + ` ★ enjoying Sipcode? a GitHub star helps other devs find it:`, + ` https://github.com/Anuj7411/sipcode`, + ].join("\n"), + statsNoSessionsYet: () => [ `no Claude Code sessions found yet.`, diff --git a/src/lib/starNudge.ts b/src/lib/starNudge.ts new file mode 100644 index 0000000..b5b349a --- /dev/null +++ b/src/lib/starNudge.ts @@ -0,0 +1,42 @@ +/** + * One-time "star us on GitHub" nudge. + * + * Shown once per machine, ever, at a moment of demonstrated value (after + * `sipcode proxy --stats` reports real savings). Gated on a marker file under + * ~/.sipcode so it never repeats and never blocks. Makes NO network call: it + * only decides whether to print a line and writes a local marker. + */ +import path from "node:path"; + +export interface StarNudgeIO { + /** True if the nudge has already been shown (marker exists). */ + hasMarker(absPath: string): Promise; + /** Write the marker so the nudge never shows again. */ + writeMarker(absPath: string, content: string): Promise; +} + +const MARKER_CONTENT = "sipcode-star-nudge/1"; + +/** Path to the one-time marker under the user's ~/.sipcode directory. */ +export function starNudgeMarkerPath(homeDir: string): string { + return path.join(homeDir, ".sipcode", ".star-nudge"); +} + +/** + * Returns true the first time it is called for a machine, then false forever + * after. Writing the marker is best-effort: if the write fails we still return + * true this once (a rare repeat beats crashing a stats command). + */ +export async function shouldShowStarNudge( + io: StarNudgeIO, + homeDir: string, +): Promise { + const marker = starNudgeMarkerPath(homeDir); + if (await io.hasMarker(marker)) return false; + try { + await io.writeMarker(marker, MARKER_CONTENT); + } catch { + /* best-effort: still nudge once even if the marker write fails */ + } + return true; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 910de17..f4c1698 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -51,9 +51,13 @@ import { listAllSessions, findSessionById, resolveProjectsDir, + type SessionMeta, } from "../modules/transcript/discover.js"; import { parseTranscriptVerbose } from "../modules/transcript/parse.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { analyzeTopExpensive } from "../modules/transcript/analyzers/topExpensive.js"; @@ -242,11 +246,30 @@ async function toolAuditLatestSession( const sessions = await listAllSessions(fs, projectsDir); if (sessions.length === 0) return fail("No sessions to audit."); - let chosen = sessions[0]; + let chosen: SessionMeta | undefined; if (opts.sessionId) { const match = await findSessionById(fs, projectsDir, opts.sessionId); if (!match) return fail(`No session matches "${opts.sessionId}".`); chosen = match; + } else { + // Auto-pick the newest NON-empty session; an empty/in-flight latest + // session would otherwise return an all-zero audit. Falls back to newest. + for (const m of sessions) { + try { + const c = await readTranscript(fs, m.filePath); + const { session } = parseTranscriptVerbose(c); + const date = session.startedAt + ? new Date(session.startedAt) + : clock.now(); + if (!isEmptySession(analyzeTokens(session, loadPricingForDate(date)))) { + chosen = m; + break; + } + } catch { + continue; + } + } + chosen = chosen ?? sessions[0]; } if (!chosen) return fail("No sessions to audit."); diff --git a/src/modules/proxy/rewriters/base.ts b/src/modules/proxy/rewriters/base.ts index 1f1c54c..5379935 100644 --- a/src/modules/proxy/rewriters/base.ts +++ b/src/modules/proxy/rewriters/base.ts @@ -50,3 +50,27 @@ function escapeRegex(s: string): string { export function hasOutputLimit(cmd: string): boolean { return /\|\s*(head|tail|less|more)\b/.test(cmd); } + +/** + * Cap a command's stdout to the first `limit` lines while PRESERVING its real + * exit code. + * + * `cmd | head -N` is wrong twice over: without pipefail the pipeline returns + * head's 0 (a FAILING command looks like it succeeded), and WITH pipefail head + * closes the pipe early and SIGPIPE-kills a SUCCESSFUL long-output command + * (exit 141, a false failure). `awk 'NR<=N'` prints only the first N lines but + * reads the WHOLE stream, so the command always runs to completion (no + * SIGPIPE); `set -o pipefail` then propagates the command's true exit code + * through awk's 0. `awk` is already a proxy dependency (see the cat rewriter). + * + * @param mergeStderr fold stderr into the captured stdout — for diagnostic + * commands (tsc, npm) whose errors must be visible and capped together. + */ +export function capLines( + cmd: string, + limit: number, + mergeStderr = false, +): string { + const merge = mergeStderr ? " 2>&1" : ""; + return `set -o pipefail; ${cmd.trim()}${merge} | awk 'NR<=${limit}'`; +} diff --git a/src/modules/proxy/rewriters/cat.ts b/src/modules/proxy/rewriters/cat.ts index e046383..7f7e408 100644 --- a/src/modules/proxy/rewriters/cat.ts +++ b/src/modules/proxy/rewriters/cat.ts @@ -23,12 +23,15 @@ const THRESHOLD = HEAD + TAIL; // only elide when the file exceeds this export const rewriteCat: RewriterFn = (input) => { const cmd = String(input.command ?? "").trim(); - if (!commandStartsWith(cmd, "cat") && !commandStartsWith(cmd, "type")) return null; + // Only `cat`. NOT `type`: in bash (which Claude Code runs) `type` is a + // builtin that reports what kind of command a name is — it does NOT read + // files — so rewriting it to awk would change its meaning entirely. + if (!commandStartsWith(cmd, "cat")) return null; if (cmd.includes("|") || cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) { return null; } // Match: `cat ` or `cat - `. Multi-file → skip. - const m = cmd.match(/^(?:cat|type)(?:\s+-[^\s]+)?\s+(\S+)\s*$/); + const m = cmd.match(/^cat(?:\s+-[^\s]+)?\s+(\S+)\s*$/); if (!m) return null; const file = m[1]!; // Single-pass, size-aware. Buffers lines, then prints full content for small diff --git a/src/modules/proxy/rewriters/find.ts b/src/modules/proxy/rewriters/find.ts index d6476f5..8683720 100644 --- a/src/modules/proxy/rewriters/find.ts +++ b/src/modules/proxy/rewriters/find.ts @@ -3,7 +3,7 @@ * find typically returns more entries than ls so the cap is higher. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasOutputLimit } from "./base.js"; +import { commandStartsWith, hasOutputLimit, capLines } from "./base.js"; const HEAD_LIMIT = 100; @@ -14,7 +14,7 @@ export const rewriteFind: RewriterFn = (input) => { if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";") || cmd.includes("|")) { return null; } - const updated = `${cmd} | head -${HEAD_LIMIT}`; + const updated = capLines(cmd, HEAD_LIMIT); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 2500, diff --git a/src/modules/proxy/rewriters/git.ts b/src/modules/proxy/rewriters/git.ts index 17dcf0c..7f34399 100644 --- a/src/modules/proxy/rewriters/git.ts +++ b/src/modules/proxy/rewriters/git.ts @@ -7,7 +7,12 @@ * tool I/O at the hook layer. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; const DIFF_HEAD = 200; @@ -74,7 +79,7 @@ export const rewriteGitDiff: RewriterFn = (input) => { return null; } return { - updatedInput: { ...input, command: `${cmd} | head -${DIFF_HEAD}` }, + updatedInput: { ...input, command: capLines(cmd, DIFF_HEAD) }, savedTokensEstimate: 3500, rewriterName: "git-diff", integrityScore: 0.5, diff --git a/src/modules/proxy/rewriters/grep.ts b/src/modules/proxy/rewriters/grep.ts index c56ea9d..422e471 100644 --- a/src/modules/proxy/rewriters/grep.ts +++ b/src/modules/proxy/rewriters/grep.ts @@ -7,7 +7,13 @@ * For non-recursive grep without an output limit, pipe to `| head -50`. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit, hasShortFlag } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + hasShortFlag, + capLines, +} from "./base.js"; const HEAD_LIMIT = 50; @@ -23,7 +29,30 @@ export const rewriteGrep: RewriterFn = (input) => { const summaryMode = countMode || listMode; if (recursive && !summaryMode) { - // Switch recursive grep to count mode (1 line per file). + // If the caller explicitly asked for matching lines, line numbers, or + // context, collapsing to -c (per-file counts) would throw away exactly that + // content. Cap volume with head instead so the requested lines survive. + const wantsLines = + hasShortFlag(cmd, "n") || + hasShortFlag(cmd, "o") || + hasShortFlag(cmd, "A") || + hasShortFlag(cmd, "B") || + hasShortFlag(cmd, "C") || + hasFlag(cmd, "--line-number", "--only-matching", "--context"); + if (wantsLines) { + if (!hasOutputLimit(cmd) && !cmd.includes("|")) { + return { + updatedInput: { ...input, command: capLines(cmd, HEAD_LIMIT) }, + savedTokensEstimate: 1500, + rewriterName: "grep", + integrityScore: 0.6, + integrityNote: + "kept first 50 matching lines via head; later matches dropped", + }; + } + return null; + } + // Plain recursive grep (no line/context flags): collapse to per-file counts. const updated = cmd.replace(/^(\s*(?:grep|rg))/, "$1 -c"); return { updatedInput: { ...input, command: updated }, @@ -37,7 +66,7 @@ export const rewriteGrep: RewriterFn = (input) => { // Non-recursive grep without an output cap → pipe to head. if (!recursive && !hasOutputLimit(cmd) && !cmd.includes("|")) { return { - updatedInput: { ...input, command: `${cmd} | head -${HEAD_LIMIT}` }, + updatedInput: { ...input, command: capLines(cmd, HEAD_LIMIT) }, savedTokensEstimate: 1500, rewriterName: "grep", integrityScore: 0.6, diff --git a/src/modules/proxy/rewriters/ls.ts b/src/modules/proxy/rewriters/ls.ts index c53a6b5..c5a8bd6 100644 --- a/src/modules/proxy/rewriters/ls.ts +++ b/src/modules/proxy/rewriters/ls.ts @@ -3,7 +3,7 @@ * Appends `| head -50` when output is not already length-limited. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasOutputLimit } from "./base.js"; +import { commandStartsWith, hasOutputLimit, capLines } from "./base.js"; const HEAD_LIMIT = 50; @@ -16,7 +16,7 @@ export const rewriteLs: RewriterFn = (input) => { if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";") || cmd.includes("|")) { return null; } - const updated = `${cmd} | head -${HEAD_LIMIT}`; + const updated = capLines(cmd, HEAD_LIMIT); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 1500, diff --git a/src/modules/proxy/rewriters/npm.ts b/src/modules/proxy/rewriters/npm.ts index f38f5d6..2c184a6 100644 --- a/src/modules/proxy/rewriters/npm.ts +++ b/src/modules/proxy/rewriters/npm.ts @@ -10,7 +10,12 @@ * piped output, or asked for json. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; export const rewriteNpmLs: RewriterFn = (input) => { const cmd = String(input.command ?? ""); @@ -80,7 +85,7 @@ export const rewriteNpmView: RewriterFn = (input) => { // Only cap when no field is specified (full dump is the verbose case). const tokens = cmd.trim().split(/\s+/).filter((t) => !t.startsWith("-")); if (tokens.length !== 3) return null; - const updated = `${cmd.trim()} 2>&1 | head -80`; + const updated = capLines(cmd, 80, true); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 2500, diff --git a/src/modules/proxy/rewriters/tsc.ts b/src/modules/proxy/rewriters/tsc.ts index be7054d..38c0115 100644 --- a/src/modules/proxy/rewriters/tsc.ts +++ b/src/modules/proxy/rewriters/tsc.ts @@ -12,7 +12,12 @@ * - --version is the only flag (already terse) */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; const HEAD_LIMIT = 100; @@ -27,9 +32,9 @@ export const rewriteTsc: RewriterFn = (input) => { if (hasFlag(cmd, "--listFiles", "--listEmittedFiles", "--version", "-v")) { return null; } - // Append a head cap. Stderr-to-stdout merge ensures we cap diagnostic output, - // not just successful builds. - const updated = `${cmd.trim()} 2>&1 | head -${HEAD_LIMIT}`; + // Cap diagnostic output (stderr merged) while preserving tsc's real exit + // code, so a FAILING typecheck is never reported to Claude as success. + const updated = capLines(cmd, HEAD_LIMIT, true); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 3000, diff --git a/src/modules/transcript/discover.ts b/src/modules/transcript/discover.ts index 44ff85e..ee5cc1d 100644 --- a/src/modules/transcript/discover.ts +++ b/src/modules/transcript/discover.ts @@ -108,12 +108,23 @@ export async function listSessionsHere( // Match by cwd path → claude-code style hash. // Windows: C:\Projects\Sipcode -> "C--Projects-Sipcode" // POSIX: /home/u/proj -> "-home-u-proj" - const cwdHash = cwd - .replace(/:/g, "-") - .replace(/[\\/]/g, "-"); + const cwdHash = cwdToProjectHash(cwd); return all.filter((s) => s.projectHash === cwdHash || cwdHash.endsWith(s.projectHash)); } +/** + * Encode a working-directory path the way Claude Code names its project dirs: + * EVERY character that is not a letter or digit collapses to "-". Verified + * empirically against ~/.claude/projects — Claude Code turns + * "C:\Projects\just research" into "C--Projects-just-research" and ".claude-mem" + * into "--claude-mem" (the dot is replaced too). Matching only ":/\ + whitespace" + * is not enough: any path with a dot, parenthesis, underscore, etc. would + * mismatch and `--here` would silently find nothing. + */ +export function cwdToProjectHash(cwd: string): string { + return cwd.replace(/[^A-Za-z0-9]/g, "-"); +} + export async function findSessionById( fs: FileSystem, projectsDir: string, diff --git a/tests/integration/why.integration.test.ts b/tests/integration/why.integration.test.ts index 085d258..9f7d704 100644 --- a/tests/integration/why.integration.test.ts +++ b/tests/integration/why.integration.test.ts @@ -36,6 +36,35 @@ function makeEnv(): FakeProcessEnv { } describe("runWhy integration", () => { + it("auto-pick skips an empty/in-flight latest session", async () => { + const fs = new InMemoryFs(); + fs.writeFile( + "/home/u/.claude/projects/test-proj/readheavy1.jsonl", + loadFixture("read-heavy.jsonl"), + new Date("2026-05-02T09:00:30Z").getTime(), + ); + // NEWEST file, but empty — must be skipped in favour of the real one. + fs.writeFile( + "/home/u/.claude/projects/test-proj/empty-latest.jsonl", + loadFixture("empty.jsonl"), + new Date("2026-05-10T09:00:30Z").getTime(), + ); + const out: string[] = []; + const result = await runWhy( + { json: true }, + { + fs, + env: makeEnv(), + clock: new FakeClock(new Date("2026-05-15T00:00:00Z")), + stdout: (s) => out.push(s), + stderr: () => {}, + }, + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.header.sessionIdShort).toBe("readheav"); + }); + it("--json on most recent (read-heavy) returns valid JSON with savings", async () => { const out: string[] = []; const err: string[] = []; diff --git a/tests/lib/starNudge.test.ts b/tests/lib/starNudge.test.ts new file mode 100644 index 0000000..7d7d225 --- /dev/null +++ b/tests/lib/starNudge.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; +import { + shouldShowStarNudge, + starNudgeMarkerPath, +} from "../../src/lib/starNudge.js"; + +describe("shouldShowStarNudge", () => { + it("returns true the first time and writes the marker", async () => { + const writeMarker = vi.fn(async () => {}); + const io = { hasMarker: vi.fn(async () => false), writeMarker }; + const r = await shouldShowStarNudge(io, "/home/u"); + expect(r).toBe(true); + expect(writeMarker).toHaveBeenCalledWith( + starNudgeMarkerPath("/home/u"), + expect.any(String), + ); + }); + + it("returns false when the marker already exists (never repeats)", async () => { + const writeMarker = vi.fn(async () => {}); + const io = { hasMarker: vi.fn(async () => true), writeMarker }; + const r = await shouldShowStarNudge(io, "/home/u"); + expect(r).toBe(false); + expect(writeMarker).not.toHaveBeenCalled(); + }); + + it("still returns true once if the marker write fails (best-effort, no crash)", async () => { + const io = { + hasMarker: vi.fn(async () => false), + writeMarker: vi.fn(async () => { + throw new Error("EPERM"); + }), + }; + await expect(shouldShowStarNudge(io, "/home/u")).resolves.toBe(true); + }); + + it("puts the marker under ~/.sipcode", () => { + expect(starNudgeMarkerPath("/home/u").replace(/\\/g, "/")).toBe( + "/home/u/.sipcode/.star-nudge", + ); + }); +}); diff --git a/tests/modules/proxy/proxy-command.test.ts b/tests/modules/proxy/proxy-command.test.ts index 9d3e84f..af689e6 100644 --- a/tests/modules/proxy/proxy-command.test.ts +++ b/tests/modules/proxy/proxy-command.test.ts @@ -102,5 +102,44 @@ describe("runProxy", () => { expect(report.schemaVersion).toBe("sipcode-proxy/2"); expect(report.totalInvocations).toBe(0); }); + + it("shows the star nudge once when there are rewrites, then never again", async () => { + const dir = join(home, ".sipcode", "proxy-stats"); + await writeStats(dir, { + timestamp: "t", + toolName: "Bash", + rewriterName: "git-status", + savedTokensEstimate: 800, + }); + const first: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => first.push(s) }); + expect(first.join("\n")).toContain("a GitHub star helps"); + + const second: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => second.push(s) }); + expect(second.join("\n")).not.toContain("a GitHub star helps"); + }); + + it("never shows the star nudge in --json mode", async () => { + const dir = join(home, ".sipcode", "proxy-stats"); + await writeStats(dir, { + timestamp: "t", + toolName: "Bash", + rewriterName: "git-status", + savedTokensEstimate: 800, + }); + const out: string[] = []; + await runProxy( + { stats: true, json: true }, + { homeDir: home, stdout: (s) => out.push(s) }, + ); + expect(out.join("\n")).not.toContain("a GitHub star helps"); + }); + + it("does not nudge when there are zero rewrites", async () => { + const out: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => out.push(s) }); + expect(out.join("\n")).not.toContain("a GitHub star helps"); + }); }); }); diff --git a/tests/modules/proxy/rewriters/cat.test.ts b/tests/modules/proxy/rewriters/cat.test.ts index ad6c0e6..4e268ea 100644 --- a/tests/modules/proxy/rewriters/cat.test.ts +++ b/tests/modules/proxy/rewriters/cat.test.ts @@ -29,4 +29,8 @@ describe("rewriteCat", () => { it("does NOT match `category` substring", () => { expect(rewriteCat({ command: "category foo" })).toBeNull(); }); + it("does NOT rewrite `type` (bash builtin, not a file reader)", () => { + expect(rewriteCat({ command: "type foo.txt" })).toBeNull(); + expect(rewriteCat({ command: "type ls" })).toBeNull(); + }); }); diff --git a/tests/modules/proxy/rewriters/find.test.ts b/tests/modules/proxy/rewriters/find.test.ts index 80be6c1..468036a 100644 --- a/tests/modules/proxy/rewriters/find.test.ts +++ b/tests/modules/proxy/rewriters/find.test.ts @@ -4,11 +4,13 @@ import { rewriteFind } from "../../../../src/modules/proxy/rewriters/find.js"; describe("rewriteFind", () => { it("appends `| head -100` to `find . -name '*.ts'`", () => { const r = rewriteFind({ command: "find . -name '*.ts'" }); - expect(r?.updatedInput.command).toBe("find . -name '*.ts' | head -100"); + expect(r?.updatedInput.command).toBe( + "set -o pipefail; find . -name '*.ts' | awk 'NR<=100'", + ); expect(r?.rewriterName).toBe("find"); }); it("works for fd too", () => { - expect(rewriteFind({ command: "fd .ts" })?.updatedInput.command).toBe("fd .ts | head -100"); + expect(rewriteFind({ command: "fd .ts" })?.updatedInput.command).toBe("set -o pipefail; fd .ts | awk 'NR<=100'"); }); it("does NOT rewrite when already piped to head", () => { expect(rewriteFind({ command: "find . | head -5" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/git.test.ts b/tests/modules/proxy/rewriters/git.test.ts index 33d1518..dfc3522 100644 --- a/tests/modules/proxy/rewriters/git.test.ts +++ b/tests/modules/proxy/rewriters/git.test.ts @@ -66,14 +66,14 @@ describe("rewriteGitLog", () => { }); describe("rewriteGitDiff", () => { - it("caps `git diff` with | head -200", () => { + it("caps `git diff` to 200 lines (awk) preserving exit code", () => { const r = rewriteGitDiff({ command: "git diff" }); - expect(r?.updatedInput.command).toBe("git diff | head -200"); + expect(r?.updatedInput.command).toBe("set -o pipefail; git diff | awk 'NR<=200'"); expect(r?.rewriterName).toBe("git-diff"); }); it("caps `git show` too", () => { expect(rewriteGitDiff({ command: "git show" })?.updatedInput.command).toBe( - "git show | head -200", + "set -o pipefail; git show | awk 'NR<=200'", ); }); it("does NOT rewrite compact summary modes", () => { diff --git a/tests/modules/proxy/rewriters/grep.test.ts b/tests/modules/proxy/rewriters/grep.test.ts index 31a6853..85c6786 100644 --- a/tests/modules/proxy/rewriters/grep.test.ts +++ b/tests/modules/proxy/rewriters/grep.test.ts @@ -7,6 +7,13 @@ describe("rewriteGrep", () => { expect(r?.updatedInput.command).toBe("grep -c -r foo ."); expect(r?.rewriterName).toBe("grep"); }); + it("recursive grep with -n keeps the lines (head cap, not -c)", () => { + const r = rewriteGrep({ command: "grep -rn foo src/" }); + // must NOT collapse to -c — that would drop the line numbers Claude asked for + expect(r?.updatedInput.command).toBe( + "set -o pipefail; grep -rn foo src/ | awk 'NR<=50'", + ); + }); it("does NOT add -c when already in count mode", () => { expect(rewriteGrep({ command: "grep -rc foo ." })).toBeNull(); }); @@ -18,7 +25,9 @@ describe("rewriteGrep", () => { }); it("pipes non-recursive grep to head when no output limit", () => { const r = rewriteGrep({ command: "grep foo file.txt" }); - expect(r?.updatedInput.command).toBe("grep foo file.txt | head -50"); + expect(r?.updatedInput.command).toBe( + "set -o pipefail; grep foo file.txt | awk 'NR<=50'", + ); }); it("does NOT rewrite non-recursive grep already piped to head", () => { expect(rewriteGrep({ command: "grep foo file.txt | head -10" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/ls.test.ts b/tests/modules/proxy/rewriters/ls.test.ts index e76957d..5e56924 100644 --- a/tests/modules/proxy/rewriters/ls.test.ts +++ b/tests/modules/proxy/rewriters/ls.test.ts @@ -4,14 +4,14 @@ import { rewriteLs } from "../../../../src/modules/proxy/rewriters/ls.js"; describe("rewriteLs", () => { it("appends `| head -50` to bare `ls`", () => { const r = rewriteLs({ command: "ls" }); - expect(r?.updatedInput.command).toBe("ls | head -50"); + expect(r?.updatedInput.command).toBe("set -o pipefail; ls | awk 'NR<=50'"); expect(r?.rewriterName).toBe("ls"); }); it("appends `| head -50` to `ls /tmp`", () => { - expect(rewriteLs({ command: "ls /tmp" })?.updatedInput.command).toBe("ls /tmp | head -50"); + expect(rewriteLs({ command: "ls /tmp" })?.updatedInput.command).toBe("set -o pipefail; ls /tmp | awk 'NR<=50'"); }); it("works with flags", () => { - expect(rewriteLs({ command: "ls -la /var" })?.updatedInput.command).toBe("ls -la /var | head -50"); + expect(rewriteLs({ command: "ls -la /var" })?.updatedInput.command).toBe("set -o pipefail; ls -la /var | awk 'NR<=50'"); }); it("does NOT rewrite when already piped to head", () => { expect(rewriteLs({ command: "ls | head -10" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/npm.test.ts b/tests/modules/proxy/rewriters/npm.test.ts index 2816e97..09c8f0f 100644 --- a/tests/modules/proxy/rewriters/npm.test.ts +++ b/tests/modules/proxy/rewriters/npm.test.ts @@ -50,7 +50,7 @@ describe("rewriteNpmView", () => { it("appends head -80 to `npm view ` with no field arg", () => { const r = rewriteNpmView({ command: "npm view react" }); expect(r).not.toBeNull(); - expect(r!.updatedInput.command).toContain("| head -80"); + expect(r!.updatedInput.command).toContain("awk 'NR<=80'"); expect(r!.rewriterName).toBe("npm-view"); }); diff --git a/tests/modules/proxy/rewriters/tsc.test.ts b/tests/modules/proxy/rewriters/tsc.test.ts index 33dee60..d49fcec 100644 --- a/tests/modules/proxy/rewriters/tsc.test.ts +++ b/tests/modules/proxy/rewriters/tsc.test.ts @@ -4,8 +4,11 @@ import { rewriteTsc } from "../../../../src/modules/proxy/rewriters/tsc.js"; describe("rewriteTsc", () => { it("appends 2>&1 | head -100 to a bare tsc call", () => { const r = rewriteTsc({ command: "tsc" }); + // pipefail must be present so a failing tsc propagates its exit code + // through head instead of head masking it as success. + expect(r!.updatedInput.command).toContain("set -o pipefail;"); expect(r).not.toBeNull(); - expect(r!.updatedInput.command).toContain("| head -100"); + expect(r!.updatedInput.command).toContain("awk 'NR<=100'"); expect(r!.updatedInput.command).toContain("2>&1"); expect(r!.rewriterName).toBe("tsc"); }); @@ -14,7 +17,7 @@ describe("rewriteTsc", () => { const r = rewriteTsc({ command: "tsc --noEmit" }); expect(r).not.toBeNull(); expect(r!.updatedInput.command).toContain("tsc --noEmit"); - expect(r!.updatedInput.command).toContain("| head -100"); + expect(r!.updatedInput.command).toContain("awk 'NR<=100'"); }); it("works on npx tsc", () => { diff --git a/tests/modules/transcript/cwdToProjectHash.test.ts b/tests/modules/transcript/cwdToProjectHash.test.ts new file mode 100644 index 0000000..608c1ff --- /dev/null +++ b/tests/modules/transcript/cwdToProjectHash.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { cwdToProjectHash } from "../../../src/modules/transcript/discover.js"; + +describe("cwdToProjectHash", () => { + it("matches Claude Code's dir name for a Windows path WITH A SPACE", () => { + // regression: "just research" must become "just-research", not keep the space + expect(cwdToProjectHash("C:\\Projects\\just research")).toBe( + "C--Projects-just-research", + ); + }); + + it("matches a plain Windows path (no regression for the common case)", () => { + expect(cwdToProjectHash("C:\\Projects\\Sipcode")).toBe( + "C--Projects-Sipcode", + ); + }); + + it("encodes a POSIX path", () => { + expect(cwdToProjectHash("/home/u/proj")).toBe("-home-u-proj"); + }); + + it("collapses tabs and runs of whitespace to dashes", () => { + expect(cwdToProjectHash("C:\\a b\tc")).toBe("C--a-b-c"); + }); + + it("replaces dots and other non-alphanumerics (Claude Code does too)", () => { + // ~/.claude/projects shows ".claude-mem" stored as "--claude-mem" + expect(cwdToProjectHash("C:\\Projects\\my.app")).toBe("C--Projects-my-app"); + expect(cwdToProjectHash("/home/u/.claude-mem")).toBe("-home-u--claude-mem"); + expect(cwdToProjectHash("C:\\Projects\\app (2)")).toBe("C--Projects-app--2-"); + }); +});