diff --git a/CHANGELOG.md b/CHANGELOG.md index 6905142..e4bd24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,11 @@ All notable changes to this project are documented here, following [Keep a Changelog](https://keepachangelog.com/) and semantic versioning. -## [0.1.17] - 2026-09-23 +## [0.1.18] - 2026-09-23 ### Fixed -- Recognize AVIF, HEIC, HEIF, APNG, WebM, Ogg, FLAC and M4A as binary assets, avoiding text reads and inflated token estimates. -- Repair unresolved merge markers in the package lockfile while updating release metadata. +- Report ignore-file write failures with a concise error and exit code 2 instead of an uncaught stack trace. ## [0.1.16] - 2026-09-17 diff --git a/README.md b/README.md index 6b38317..768356a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ errors (exit code 2), detected before scanning or writing files. **Ignore files it writes** (all real formats the tools honor): `.cursorignore` (Cursor), `.aiexclude` (Gemini Code Assist / Firebase Studio), `.aiignore` (generic). Writes are **idempotent** — a managed block between markers, so your own rules are preserved and re-runs just update the block. +If an ignore file cannot be read or written, the command prints a concise error and exits with code 2 without reporting success. Targets written before a later failure remain updated; writes are not rolled back. + Repeated names in `--targets` are ignored; each ignore file is written and reported once, in the order first requested. ## What it is (and isn't) diff --git a/package-lock.json b/package-lock.json index 4f11004..e9fe25e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ctxtrim", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ctxtrim", - "version": "0.1.17", + "version": "0.1.18", "license": "MIT", "bin": { "ctxtrim": "bin/ctxtrim.js" diff --git a/package.json b/package.json index 726111b..f5ec970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ctxtrim", - "version": "0.1.17", + "version": "0.1.18", "description": "Trim what bloats your AI coding context. Scan a repo, find the high-cost/low-value files ballooning your Claude Code / Cursor / Codex context, and write ignore files to cut token cost. Zero dependencies.", "type": "module", "bin": { diff --git a/src/cli.js b/src/cli.js index 90eb5e6..e002b67 100644 --- a/src/cli.js +++ b/src/cli.js @@ -89,7 +89,9 @@ export function run(argv, { version }) { try { scan = scanRepo(target, { maxTokens: o.maxTokens }); } catch (error) { process.stderr.write(`ctxtrim: ${error.message}\n`); return 2; } let wrote = null; - if (o.write && scan.patterns.length) wrote = writeIgnores(scan.root, scan.patterns, targets); + try { + if (o.write && scan.patterns.length) wrote = writeIgnores(scan.root, scan.patterns, targets); + } catch (error) { process.stderr.write(`ctxtrim: ${error.message}\n`); return 2; } if (o.format === "json") process.stdout.write(jsonReport(scan, { price: o.price, wrote }) + "\n"); else process.stdout.write(textReport(scan, { price: o.price, top: o.top, wrote })); diff --git a/test/write-errors.test.js b/test/write-errors.test.js new file mode 100644 index 0000000..4470688 --- /dev/null +++ b/test/write-errors.test.js @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { run } from "../src/cli.js"; + +for (const format of ["text", "json"]) { + for (const failTarget of [".cursorignore", ".aiexclude"]) { + test(`CLI reports write failure cleanly (${format}, ${failTarget})`, (t) => { + const root = fs.mkdtempSync(join(tmpdir(), "ctxtrim-write-error-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(join(root, "package-lock.json"), "{}\n"); + const originalWrite = fs.writeFileSync; + let stdout = "", stderr = ""; + t.mock.method(process.stdout, "write", (chunk) => { stdout += chunk; return true; }); + t.mock.method(process.stderr, "write", (chunk) => { stderr += chunk; return true; }); + t.mock.method(fs, "writeFileSync", (file, ...args) => { + if (file === join(root, failTarget)) { + const error = new Error(`EACCES: permission denied, open '${file}'`); + error.code = "EACCES"; + throw error; + } + return originalWrite(file, ...args); + }); + syncBuiltinESMExports(); + let code; + try { code = run([root, "--write", "--format", format], { version: "test" }); } + finally { t.mock.restoreAll(); syncBuiltinESMExports(); } + + assert.equal(code, 2); + assert.equal(stdout, ""); + assert.equal(stderr, `ctxtrim: EACCES: permission denied, open '${join(root, failTarget)}'\n`); + assert.equal(fs.existsSync(join(root, failTarget)), false); + // Earlier targets can already have been written; error handling is not rollback. + assert.equal(fs.existsSync(join(root, ".cursorignore")), failTarget === ".aiexclude"); + }); + } +}