feat(setup): report an unwritable target as a structured error (task 0044)
All checks were successful
CI / test (22) (pull_request) Successful in 50s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 47s
CI / test (true, 24) (push) Successful in 1m4s
CI / flake (push) Successful in 3s

Both halves of `setup` assumed the files they manage are writable. A
declaratively managed target — read-only because a configuration manager
owns it, because a file is flagged immutable, or because the path is
root-owned — made the skill install raise a raw filesystem exception and
the hook install surface the underlying message with no guidance.

Both now fail with `TARGET_NOT_WRITABLE`, naming the file and pointing at
the general remedy: it appears to be managed by another tool, so declare
the skill or hook through that configuration instead. The error names no
particular manager, because read-only is not diagnostic of one.

A target already byte-identical to the bundled copy still succeeds —
nothing needs writing, so its being read-only is beside the point.
This commit was merged in pull request #53.
This commit is contained in:
2026-07-20 13:25:03 -04:00
parent 27aad04984
commit a1e68dc530
5 changed files with 283 additions and 23 deletions

View File

@@ -491,7 +491,7 @@ The dashboard's empty states are `prs: 0 open` / `issues: 0 open` (raw strings,
Empty output is never silent. Empty output is never silent.
**Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.** **Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.**
Errors are represented as a typed `AxiError` with one of ten named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `UNKNOWN`. Errors are represented as a typed `AxiError` with one of eleven named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `TARGET_NOT_WRITABLE`, `UNKNOWN`.
The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification. The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification.
API error responses are classified by HTTP status code and calling context: API error responses are classified by HTTP status code and calling context:
@@ -516,6 +516,8 @@ tea has logins but none match the detected hostname → `REPO_NOT_FOUND` (the re
HTTP 401 from the API → `AUTH_REQUIRED` (token invalid or revoked), per the status table. HTTP 401 from the API → `AUTH_REQUIRED` (token invalid or revoked), per the status table.
A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names. A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names.
`GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line. `GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line.
`TARGET_NOT_WRITABLE` classifies a `setup` target the filesystem refuses (`EACCES`, `EPERM`, `EROFS`), naming the file and pointing at the general remedy: it appears to be managed by another tool, so the skill or hook belongs in that tool's configuration.
It never names or infers a particular configuration manager — read-only is not diagnostic of one.
Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines. Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines.
The suggestions field is named `help`, not `hint`. The suggestions field is named `help`, not `hint`.
Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004). Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004).

View File

@@ -21,9 +21,32 @@ The failure follows the CLI's existing error convention rather than inventing a
## Acceptance criteria ## Acceptance criteria
- [ ] An unwritable skill target produces a structured CLI error rather than an unhandled filesystem exception. - [x] An unwritable skill target produces a structured CLI error rather than an unhandled filesystem exception.
- [ ] An unwritable hook target produces the same class of error, with the same guidance. - [x] An unwritable hook target produces the same class of error, with the same guidance.
- [ ] Both errors name the file that could not be written and state that it appears to be managed by another tool. - [x] Both errors name the file that could not be written and state that it appears to be managed by another tool.
- [ ] Neither error names or infers a specific configuration manager. - [x] Neither error names or infers a specific configuration manager.
- [ ] A skill target that is unwritable but already byte-identical to the bundled copy succeeds rather than failing, since nothing needs to be written. - [x] A skill target that is unwritable but already byte-identical to the bundled copy succeeds rather than failing, since nothing needs to be written.
- [ ] The errors carry a code and help lines consistent with the rest of the CLI's error surface. - [x] The errors carry a code and help lines consistent with the rest of the CLI's error surface.
## Implementation Notes
The condition is `EACCES`, `EPERM`, or `EROFS` — the three ways a filesystem refuses a write for a reason the user has to settle outside this tool.
The new `TARGET_NOT_WRITABLE` code joins the spec's enumerated list, alongside a paragraph describing it.
Two things came out of review and go slightly beyond the literal criteria.
The skill half now guards its comparison read as well as its write.
A target the filesystem will not let us read is one it will not let us replace either — the same condition reached one call earlier — so a mode-`000` file reports the same error rather than the raw exception the criteria were written against.
The hook half collects its failures as `{path, detail}` records rather than the agent SDK's flattened `<path>: <message>` text.
The SDK reports through a string, so the two halves are separated once at that boundary and judged apart.
This matters for correctness, not just shape: testing the whole formatted string for an errno would misclassify an unrelated failure whose *path* happened to contain `EACCES`.
Two known limits, both judged acceptable rather than fixed.
The hook error names the target the SDK was writing, which is the intended path rather than necessarily the blocking one — if `~/.claude` were unwritable and `settings.json` absent, it would name the file rather than the directory.
The SDK discards the error object, so its `path` is not recoverable; the skill half, which catches its own errors, does report the blocking path and is tested for it.
An unwritable `~/.claude/settings.json` still leaves the Codex and OpenCode integrations installed, because the SDK writes them before the failure surfaces.
The command exits 1 having done part of its work.
Making the hook install transactional across three integrations owned by the SDK is a larger change than this task, and re-running after fixing the permission converges correctly.

View File

@@ -4,7 +4,12 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { installSessionStartHooks } from "axi-sdk-js"; import { installSessionStartHooks } from "axi-sdk-js";
import type { CliDeps } from "../deps.js"; import type { CliDeps } from "../deps.js";
import { axiError } from "../errors.js"; import {
axiError,
isNotWritableError,
isNotWritableMessage,
unwritableTargetError,
} from "../errors.js";
import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js"; import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js";
import { renderDetail } from "../render.js"; import { renderDetail } from "../render.js";
@@ -65,16 +70,31 @@ function installSkill(home: string): { skill: string; path: string; status: Skil
const targetPath = join(targetDir, "SKILL.md"); const targetPath = join(targetDir, "SKILL.md");
let status: SkillStatus; let status: SkillStatus;
try {
if (!existsSync(targetPath)) { if (!existsSync(targetPath)) {
status = "installed"; status = "installed";
} else { } else {
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated"; status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
} }
// A target that already holds these bytes needs no write, so its being
// read-only is beside the point and the run succeeds.
if (status !== "unchanged") { if (status !== "unchanged") {
mkdirSync(targetDir, { recursive: true }); mkdirSync(targetDir, { recursive: true });
writeFileSync(targetPath, source, "utf8"); writeFileSync(targetPath, source, "utf8");
} }
} catch (error) {
// The comparison read is inside the guard because a target the filesystem
// will not let us read is one it will not let us replace either — the same
// condition, reached one call earlier.
if (isNotWritableError(error)) {
// The path the filesystem names is the directory when it is the directory
// that is read-only, so report that rather than assuming the file.
const blocked = (error as { path?: string }).path ?? targetPath;
throw unwritableTargetError(blocked, `the ${SKILL_NAME} skill`);
}
throw error;
}
return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status }; return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status };
} }
@@ -99,6 +119,38 @@ const HOOK_SETTINGS_FILES = [
[".codex", "hooks.json"], [".codex", "hooks.json"],
]; ];
/** One target the hook install could not write, and why. */
interface TargetFailure {
/** The file being written when it failed. */
path: string;
/** The underlying failure, with no path spliced into it. */
detail: string;
}
/**
* Recover a {@link TargetFailure} from the agent SDK's reporting.
*
* It hands its failures to `onError` as `<path>: <message>` text rather than as
* errors, so the two halves have to be separated again before either can be
* judged on its own. Text in that shape is all it ever emits; anything else is
* returned whole as the detail, with no path to attribute it to.
*/
function parseReportedFailure(reported: string): TargetFailure {
const separator = reported.indexOf(": ");
if (separator === -1) {
return { path: "", detail: reported };
}
return {
path: reported.slice(0, separator),
detail: reported.slice(separator + 2),
};
}
/** A failure rendered back as the `<path>: <detail>` line a reader sees. */
function formatFailure({ path, detail }: TargetFailure): string {
return path === "" ? detail : `${path}: ${detail}`;
}
/** /**
* Collapse any duplicate managed entry the SDK's own recognition missed. * Collapse any duplicate managed entry the SDK's own recognition missed.
* *
@@ -109,7 +161,11 @@ const HOOK_SETTINGS_FILES = [
* command's shape — and cannot mistake another tool's hook for ours the way a * command's shape — and cannot mistake another tool's hook for ours the way a
* substring test can. * substring test can.
*/ */
function pruneHookSettingsFiles(home: string, command: string, errors: string[]): void { function pruneHookSettingsFiles(
home: string,
command: string,
errors: TargetFailure[],
): void {
const isManaged = (recorded: string) => recorded === command; const isManaged = (recorded: string) => recorded === command;
for (const segments of HOOK_SETTINGS_FILES) { for (const segments of HOOK_SETTINGS_FILES) {
@@ -124,14 +180,17 @@ function pruneHookSettingsFiles(home: string, command: string, errors: string[])
writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
} }
} catch (error) { } catch (error) {
errors.push(`${target}: ${error instanceof Error ? error.message : String(error)}`); errors.push({
path: target,
detail: error instanceof Error ? error.message : String(error),
});
} }
} }
} }
async function setupHooks(deps: CliDeps): Promise<string> { async function setupHooks(deps: CliDeps): Promise<string> {
const home = resolveHome(deps); const home = resolveHome(deps);
const errors: string[] = []; const errors: TargetFailure[] = [];
// ADR 0019: record a search-path name, not an install-tree path. Handing the // ADR 0019: record a search-path name, not an install-tree path. Handing the
// SDK where the binary resolves on PATH — rather than the module-relative // SDK where the binary resolves on PATH — rather than the module-relative
@@ -156,13 +215,25 @@ async function setupHooks(deps: CliDeps): Promise<string> {
// deferring to the SDK's auto-install safety gate (which is tuned for the // deferring to the SDK's auto-install safety gate (which is tuned for the
// inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use). // inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use).
shouldInstall: () => true, shouldInstall: () => true,
onError: (message) => errors.push(message), onError: (message) => errors.push(parseReportedFailure(message)),
}); });
pruneHookSettingsFiles(home, command, errors); pruneHookSettingsFiles(home, command, errors);
if (errors.length > 0) { if (errors.length > 0) {
throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN"); // A read-only target is the most actionable thing that can be in here — it
// names something the user must settle elsewhere rather than a bug — so it
// is reported ahead of whatever else was collected.
const unwritable = errors.find(
(failure) => failure.path !== "" && isNotWritableMessage(failure.detail),
);
if (unwritable) {
throw unwritableTargetError(unwritable.path, `the ${SKILL_NAME} session hook`);
}
throw axiError(
`Failed to install session hooks: ${errors.map(formatFailure).join("; ")}`,
"UNKNOWN",
);
} }
return renderDetail({ return renderDetail({

View File

@@ -10,6 +10,7 @@ export type AxiErrorCode =
| "TEA_NOT_INSTALLED" | "TEA_NOT_INSTALLED"
| "VALIDATION_ERROR" | "VALIDATION_ERROR"
| "GIT_ERROR" | "GIT_ERROR"
| "TARGET_NOT_WRITABLE"
| "UNKNOWN"; | "UNKNOWN";
export function axiError( export function axiError(
@@ -20,6 +21,50 @@ export function axiError(
return new AxiError(message, code, suggestions); return new AxiError(message, code, suggestions);
} }
// The three ways a filesystem refuses a write for reasons the user must settle
// outside this tool: the permission bits deny it, the file is flagged immutable
// or otherwise protected, or the filesystem itself is mounted read-only.
const NOT_WRITABLE_ERRNOS = ["EACCES", "EPERM", "EROFS"];
/** Whether a caught filesystem error means the target cannot be written. */
export function isNotWritableError(error: unknown): boolean {
const errno = (error as { code?: unknown } | null)?.code;
return typeof errno === "string" && NOT_WRITABLE_ERRNOS.includes(errno);
}
/**
* The same judgement made from an error's *message*, for a caller handed the
* formatted text rather than the error object.
*
* This takes the message alone, never a string the target's path has been
* spliced into: a path is the user's to name, and one that happened to contain
* `EACCES` would otherwise misreport an unrelated failure as a read-only target.
*/
export function isNotWritableMessage(message: string): boolean {
return NOT_WRITABLE_ERRNOS.some((errno) => message.includes(errno));
}
/**
* A read-only target reported as something the user can act on.
*
* The remedy is deliberately general. Read-only is not diagnostic of any
* particular configuration manager, and every plausible cause — a declarative
* home manager, an immutable flag, a root-owned path — has the same answer:
* whatever renders the file read-only is where this belongs, not here.
*
* `subject` names what the caller was installing, for the remedy line.
*/
export function unwritableTargetError(path: string, subject: string): AxiError {
return axiError(
`Cannot write ${path}: it is not writable, so it appears to be managed by another tool`,
"TARGET_NOT_WRITABLE",
[
`Declare ${subject} through that tool's configuration rather than installing it with this command`,
`Or make ${path} writable and re-run`,
],
);
}
interface HttpResponseLike { interface HttpResponseLike {
status: number; status: number;
url: string; url: string;

View File

@@ -1,4 +1,13 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path"; import { delimiter, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -7,12 +16,49 @@ import { type CliResult, runCliTest } from "./harness.js";
let tempHome: string; let tempHome: string;
/** Restore write permission everywhere under `dir` so the tree can be removed. */
function restorePermissions(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
chmodSync(path, entry.isDirectory() ? 0o700 : 0o600);
if (entry.isDirectory()) {
restorePermissions(path);
}
}
}
afterEach(() => { afterEach(() => {
if (tempHome) { // Not every test creates a HOME, so this may be a directory an earlier test
// already removed.
if (tempHome && existsSync(tempHome)) {
// The read-only-target tests leave files and directories unwritable, and
// an unwritable directory cannot have its entries unlinked.
chmodSync(tempHome, 0o700);
restorePermissions(tempHome);
rmSync(tempHome, { recursive: true, force: true }); rmSync(tempHome, { recursive: true, force: true });
} }
tempHome = "";
}); });
/**
* Permission bits are not enforced for root, so the read-only-target tests
* cannot express their premise there and are skipped rather than passing
* vacuously.
*/
const itUnlessRoot = process.getuid?.() === 0 ? it.skip : it;
/**
* Assert the error's wording infers no particular configuration manager.
*
* Read-only is not diagnostic of one, so naming one would be wrong for most
* readers who hit this. The paths the error quotes are exempt — they are the
* user's own, and here the temp directory sits under a `nix-shell` TMPDIR.
*/
function expectNamesNoManager(stdout: string, home: string): void {
const wording = stdout.split(home).join("<home>");
expect(wording).not.toMatch(/\b(nix|home-manager|nixos|chezmoi|ansible|stow|guix)\b/i);
}
/** /**
* Run `body` with `process.env.PATH` replaced. `setup hooks` reads PATH from the * Run `body` with `process.env.PATH` replaced. `setup hooks` reads PATH from the
* process rather than the injected environment, because it has to agree with * process rather than the injected environment, because it has to agree with
@@ -78,6 +124,60 @@ describe("setup", () => {
expect(third.stdout).toContain("status: updated"); expect(third.stdout).toContain("status: updated");
expect(readFileSync(installedPath, "utf8")).not.toBe("tampered"); expect(readFileSync(installedPath, "utf8")).not.toBe("tampered");
}); });
itUnlessRoot("reports a read-only skill target as a structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
// A declaratively managed install in miniature: the file is present, its
// content differs from the bundled copy, and it cannot be written.
mkdirSync(join(tempHome, ".claude", "skills", "gitea-axi"), { recursive: true });
writeFileSync(installedPath, "managed elsewhere\n");
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(installedPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
// The bundled copy is untouched by a failed run.
expect(readFileSync(installedPath, "utf8")).toBe("managed elsewhere\n");
});
itUnlessRoot("names the directory when it is the directory that is read-only", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const skillsDir = join(tempHome, ".claude", "skills");
// Nothing installed yet, and no new entry can be created here — so the
// blocked path is the directory, not the file that would have gone in it.
mkdirSync(skillsDir, { recursive: true });
chmodSync(skillsDir, 0o555);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(join(skillsDir, "gitea-axi"));
expectNamesNoManager(stdout, tempHome);
});
itUnlessRoot("succeeds on a read-only skill target that is already up to date", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
// Same bytes the command would write, so there is nothing to write and the
// target's being read-only is beside the point.
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(0);
expect(stdout).toContain("status: unchanged");
});
}); });
describe("setup hooks", () => { describe("setup hooks", () => {
@@ -179,6 +279,25 @@ describe("setup hooks", () => {
expect(recordedHookCommand(tempHome)).toBe(entrypointPath()); expect(recordedHookCommand(tempHome)).toBe(entrypointPath());
}); });
itUnlessRoot("reports a read-only hook target as the same structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const settingsPath = join(tempHome, ".claude", "settings.json");
mkdirSync(join(tempHome, ".claude"), { recursive: true });
writeFileSync(settingsPath, "{}\n");
chmodSync(settingsPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(settingsPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
});
}); });
describe("setup dispatch", () => { describe("setup dispatch", () => {