Skip to content

Add claude-rc CLI, daemon and Homebrew formula - #372

Draft
nsheaps wants to merge 6 commits into
mainfrom
claude/magical-planck-7fga5y
Draft

nsheaps wants to merge 6 commits into
mainfrom
claude/magical-planck-7fga5y

Conversation

@nsheaps

@nsheaps nsheaps commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds claude-rc (CLI) and claude-rc-daemon, shipped in one Homebrew formula published to nsheaps/homebrew-devsetup.

  • Daemon: on start runs claude upgrade, reads ~/.claude/daemon/rc.json, and runs claude rc --spawn <mode> --verbose in each repo as its own service. Services auto-restart but do not survive reboot; the daemon does and recreates them.
  • Supervision: systemd user units (Linux), launchd job in gui/<uid> (macOS), in-process fallback (--supervisor).
  • Workspace trust + TTY: pre-accepts trust via ~/.claude.json; each child runs under a built-in pty-run wrapper so claude rc gets a terminal (tmux rejected, spec D6).
  • CLI: status, add/remove, start/stop/restart, reload, trust, ui, logs, config, doctor, shutdown.
  • Notifications: desktop notification when a repo's claude rc starts, exits or is auto-restarted (macOS osascript, Linux notify-send; absent notify-send is a no-op and doctor warns).
  • Update check (1x/day): claude-rc (latest claude-rc-v* GitHub release) and Claude Code (npm). Each new version is announced once; the timestamp is persisted so daemon restarts don't re-check. Each source is toggled in updates.claudeRc / updates.claude.
  • Web UI: claude-rc ui opens a page served by the daemon on 127.0.0.1 to monitor repos, add/remove/restart/stop/start them and edit settings. Token on every request, loopback Host check, same-origin Origin check, JSON-only mutations, CSP. There is no native tray icon (your choice: TypeScript only; spec D7).
  • Layout: lib/claude-rc-core, services/claude-rc-daemon, apps/claude-rc-cli.
  • Release: tag claude-rc-vX.Y.Z builds 4 compiled binaries, uploads a GitHub release, renders apps/claude-rc-cli/Formula/claude-rc.rb.gotmpl, and opens a PR in homebrew-devsetup.
  • CI: claude-rc-e2e.yaml runs e2e on ubuntu/debian/fedora containers, native systemd, and macOS launchd.

New rc.json keys (all optional, defaults shown):

{
  "notifications": { "enabled": true, "onStart": true, "onExit": true },
  "updates": { "claudeRc": true, "claude": true },
  "ui": { "port": 47474 }
}

Spec: docs/specs/draft/claude-rc.md (section 12a for this feature). Plan: .claude/plans/claude-rc.md.

Testing

Tests written first. nx run-many -t format lint build test passes for the three packages (core 302, CLI 78, daemon 66). The e2e runs the real daemon and CLI against a fake claude, a fake notify-send, and the real web API. The page was also loaded in headless Chromium (repos render, a hostile path is escaped). I also scoped the daemon test script to src because compiled dist/*.test.js copies resolved main.ts incorrectly.

Not verified yet (please check)

  • Real claude rc running after pre-trust (not available in the authoring sandbox).
  • Real systemd/launchd, Debian/Fedora native systemd, and brew install of the rendered formula are covered only by CI jobs or manual runs.
  • Real desktop notifications on a desktop session (CI only sees a fake notify-send; on macOS the osascript call can't be observed headless) and the real GitHub/npm update calls (tests inject fetch).
  • Defaults I chose, easy to change: claude upgrade failure is non-fatal; invalid rc.json leaves the daemon degraded; daemon SIGTERM stops its children; user-initiated stop does not notify.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p

…ease workflow

Daemon runs claude upgrade, reads ~/.claude/daemon/rc.json and supervises one
'claude rc' service per repo (systemd user units, launchd, or in-process
fallback). CLI talks to it over a unix socket. Standalone binaries and a
Homebrew formula template are published by release-claude-rc.yaml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
Use statSync instead of GNU stat -c, and compare against realpath of the
temp dir since macOS resolves /var to /private/var.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
claude rc exits without a TTY. The daemon now launches every child as
'claude-rc-daemon pty-run -- claude rc ...', which allocates a PTY with
Bun's terminal spawn option, mirrors output to the service log, forwards
signals and propagates the exit code. CLAUDE_RC_PTY=0 disables it.
tmux was evaluated and rejected: new-session -d detaches in ~16ms, so
systemd/launchd would loop-restart the service.

The e2e fake claude now refuses to run without a TTY, so e2e on every OS
proves the wrapper. Release workflow smoke-tests the compiled binary.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
- Desktop notifications when a repo's `claude rc` starts, exits or restarts
  (osascript on macOS, notify-send on Linux; absent notify-send is a no-op).
- Once-a-day update check for claude-rc (GitHub releases) and Claude Code
  (npm), each new version announced once; persisted in state.json.
- Loopback web UI served by the daemon (token + Host/Origin checks) to monitor,
  add/remove, restart/stop/start repos and edit settings; `claude-rc ui` opens
  it. New config keys: notifications, updates, ui.port.
- doctor warns when notify-send is missing; spec D7 records why there is no
  native tray icon.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
Minimal containers such as Fedora ship no `which` binary, so the notifier
never detected notify-send there and the e2e notification test timed out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p
@nsheaps nsheaps added the request-review Force an AI code review on a draft PR (open non-draft PRs review automatically) label Oct 13, 2026
@automation-nsheaps automation-nsheaps Bot removed the request-review Force an AI code review on a draft PR (open non-draft PRs review automatically) label Oct 13, 2026

@henry-nsheaps henry-nsheaps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Ship-worthy with two P1 safety nits — tight loopback web UI, strong test coverage, good spec

  • ⚠️ trust.ts RMW is not atomic against concurrent claude writes (spec claims it is)
  • ⚠️ IPC unix-socket perms race between listen() and chmod 0600
  • ⚠️ updates.ts only reads first 50 GitHub releases, no pagination
  • ⚠️ Env values accept \n/NUL → malformed systemd-run --setenv
  • ⚠️ Release workflow mass-closes PRs by title substring, no author scope
  • ❔ basename(path) default repo name can fail NAME_RE validation
  • ✅ Loopback UI defenses are solid (token + Host + Origin + CSP + JSON-only mutations)
  • ✅ Tests-first across every module; real-process e2e with fake claude
  • ✅ Supervisor abstraction is clean; adapters are small and swappable
  • ✅ PTY wrapper is well-justified over tmux with measurement

Click to expand for full details

Scope and context

6,242 added lines across three new packages (lib/claude-rc-core, services/claude-rc-daemon, apps/claude-rc-cli), a Homebrew formula template, a per-app release workflow, and a cross-OS e2e job. Spec at docs/specs/draft/claude-rc.md and plan at .claude/plans/claude-rc.md match the implementation closely; defaults are called out and justified (autoTrust=true, non-fatal claude upgrade failure, degraded-but-up on bad rc.json, children stop on daemon SIGTERM). No prior reviews on this PR.

How I evaluated

Read the spec and plan end-to-end, then walked the core lib (config, trust, ipc, pty-run, notify, updates, spec, reconcile, inproc, systemd, launchd, pick), the daemon (main, daemon, web, web-page, pty-cmd, upgrade), the CLI (args, commands), and the release/e2e workflows plus the formula template. Spot-checked tests for the modules with the highest risk (web, pty-run, trust, systemd, e2e).

Strengths (why this scores high)

  • Loopback UI defenses are layered: 32-byte random token with timingSafeEqual, mandatory loopback Host, same-origin Origin when sent, JSON-only mutations, strict CSP (default-src 'none', connect-src 'self'), token stripped from URL via history.replaceState and persisted only in sessionStorage, DOM built with textContent only. ui.json is written 0600 and chmodSync'd after the fact to handle pre-existing files. This is better than most locally-served admin UIs I review.
  • Tests-first discipline is real: every module has a focused unit test; apps/claude-rc-cli/src/e2e.test.ts spawns real daemon + CLI processes against a fake claude and asserts observable behavior (upgrade-then-reconcile, auto-trust writes to .claude.json, kill-and-restart gets a new pid, manual stop survives reload, invalid config keeps the current services, hand-edited rc.json is picked up by the file watcher).
  • PTY decision is well-researched: the "16ms detach" measurement rules out tmux new-session -d as default and is captured in spec D6; pty-run reports 127 on spawn failure and 128+signal on signalled exit; the fake claude rc in e2e refuses to run without a TTY, so every passing e2e run also proves the wrapper works on that OS.
  • Supervisor abstraction: Supervisor interface + Exec dep injection means systemd/launchd can be exercised in unit tests without any system state; pickSupervisor throws on an explicit native request that is unavailable (no silent degradation).
  • Workspace trust logic refuses $HOME and resolves worktree → main checkout correctly (trustKeyFor), with explicit tests.
  • Env allowlist in spec.ts keeps the daemon's incidental env (tokens, local dev paths) out of child units and plists.

P1 findings (should fix before merge)

trust.ts read-modify-write races against claude

The spec says "atomic read-modify-write (claude edits the file too)" — the rename is atomic, but the full RMW (readFileSync on line 38 → mutate → writeFileSync(tmp) on line 51 → renameSync on line 53) is not. If claude rename-replaces ~/.claude.json between our read and our rename, our rename wins and claude's edits are lost. The daemon only enters this path on reconcile and the window is small, so loss is rare, but the spec promises more than the code delivers. Options are a flock-based lock, optimistic concurrency on mtime, or weakening the spec wording. (Inline comment on lib/claude-rc-core/src/trust.ts:32-55.)

IPC socket perms race

server.listen(path) creates the socket inode with permissions derived from the current umask. chmodSync(path, 0o600) runs immediately after, but between the two syscalls the socket is reachable by other local processes (and on a user with umask 022 the inode starts life mode 0755). The parent ~/.claude/daemon is created recursively with the default mkdir mode (also umask-derived), so it doesn't close the window. Easiest fix: process.umask(0o077) around the listen, or mkdir the daemon/run/log dirs with mode: 0o700. (Inline on lib/claude-rc-core/src/ipc.ts:62-66.)

P2 findings (polish; follow-up PR is fine)

  • updates.ts pagination: ?per_page=50 with no further pages. In a monorepo publishing many apps, 50 unrelated-release stacking will hide the latest claude-rc-v* release. Not blocking today, but a hazard as the repo accrues apps.
  • config.ts env values: no rejection of control chars; a stray \n in a value turns into malformed systemd-run --setenv=KEY=multi\nline. Suggested one-line regex guard in the inline comment.
  • Release workflow gh pr list --search "chore: update claude-rc to": no author filter. A human-opened PR with a similar title gets force-closed. Scope with --author "@me" (or the App slug).
  • basename(path) default repo name can produce a name that fails [A-Za-z0-9][A-Za-z0-9._-]* validation (e.g., directory with spaces), yielding a cryptic error.
  • commands.ts past-tense surgery: cmd + "ped"|"ed" + .replace("stoped","stopped") is harder to follow than a tiny {start:"started",stop:"stopped",restart:"restarted"} map.

What I didn't verify (per PR description)

The PR description already flags these: no real claude rc run, no real systemd/launchd exercise outside CI, no real desktop notification side-effects, no real brew install. I agree these are reasonable to defer to CI and manual smoke testing — the shape of the code and tests makes them likely to work.

Verdict

COMMENT. No bug that unambiguously breaks the feature shipped here, but the two P1 races (trust-file RMW and socket chmod window) are real and worth closing before this ends up in homebrew-devsetup. The feature set is well-scoped and the tests are strong; this is close to green.

Follow-ups:

  • P1: lib/claude-rc-core/src/trust.ts — close the RMW race (flock sidecar, optimistic re-read, or weaken spec wording to match code).
  • P1: lib/claude-rc-core/src/ipc.ts — set umask around listen() or mkdir(..., {mode: 0o700}) on the daemon/run/log dirs so the socket is never world-connectable.
  • P2: lib/claude-rc-core/src/updates.ts — paginate GitHub releases (bounded) so the latest claude-rc-v* tag isn't buried by unrelated releases.
  • P2: lib/claude-rc-core/src/config.ts — reject control chars in env values.
  • P2: .github/workflows/release-claude-rc.yaml — scope the "supersede older PRs" step to the automation account.
  • P2: apps/claude-rc-cli/src/args.ts + services/claude-rc-daemon/src/web.ts — sanitize or surface the default name derived from basename(path).
  • P2 (nit): apps/claude-rc-cli/src/commands.ts — map-based past tense.

References

Comment thread lib/claude-rc-core/src/trust.ts Outdated
Comment on lines +32 to +55
export function ensureTrusted(claudeJsonPath: string, key: string): "added" | "unchanged" {
let data: Record<string, any> = {};
let mode = 0o600;
if (existsSync(claudeJsonPath)) {
mode = statSync(claudeJsonPath).mode & 0o777;
try {
data = JSON.parse(readFileSync(claudeJsonPath, "utf8"));
} catch {
throw new Error(`${claudeJsonPath} is not valid JSON; refusing to modify it`);
}
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error(`${claudeJsonPath} is not a JSON object; refusing to modify it`);
}
}
const projects = (data.projects ??= {});
const entry = (projects[key] ??= {});
if (entry.hasTrustDialogAccepted === true) return "unchanged";
entry.hasTrustDialogAccepted = true;
const tmp = `${claudeJsonPath}.claude-rc.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode });
chmodSync(tmp, mode);
renameSync(tmp, claudeJsonPath);
return "added";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Read-modify-write is not atomic against concurrent claude writers. The spec promises "atomic read-modify-write (claude edits the file too)" but the implementation is read → mutate in memory → writeFileSync(tmp) → renameSync(tmp, …). The rename is atomic for this write, but the window between the readFileSync on line 38 and the renameSync on line 53 is unlocked. If claude itself rename-replaces ~/.claude.json in that window, this call clobbers claude's changes — exactly the scenario the spec acknowledges.

The window is small (one JSON parse + stringify), and the daemon only enters it on reconcile, so in practice loss is rare. But it is a real data-loss hazard worth closing. Options:

  • Advisory lock on a sidecar file (~/.claude.json.lock) via flock(2) around the whole RMW.
  • Re-read inside the write path and bail if mtime/digest changed since the initial read (optimistic concurrency; retry a bounded number of times).
  • Document explicitly that this races and that claude-rc trust should be run only while claude is quiescent.

At minimum the spec comment ("atomic read-modify-write") should be weakened — the rename is atomic, the RMW is not.

Comment thread lib/claude-rc-core/src/ipc.ts Outdated
Comment on lines +62 to +66
await new Promise<void>((res, rej) => {
server.once("error", rej);
server.listen(path, () => res());
});
chmodSync(path, 0o600);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Socket permission race on listen() + chmod. server.listen(path) creates the inode with permissions derived from the daemon's umask (0022 on most Linux systems ⇒ socket mode 0755). The subsequent chmodSync(path, 0o600) closes the window, but any local process that races between the two syscalls can connect() and issue IPC commands to the daemon.

The ~/.claude/daemon directory is created recursively with the default mkdir mode in services/claude-rc-daemon/src/main.ts:34-35, which inherits the user's umask too — on a typical 0022 umask it ends up 0755, so parent-directory perms don't close the window either.

Suggested fix (one of):

const prev = typeof process.umask === "function" ? process.umask(0o077) : undefined;
try {
  await new Promise<void>((res, rej) => {
    server.once("error", rej);
    server.listen(path, () => res());
  });
} finally {
  if (prev !== undefined) process.umask(prev);
}
// chmodSync still useful as a belt-and-braces on platforms that honor it after bind
chmodSync(path, 0o600);

Or, ensure dirname(socketPath) is mkdir'd with mode 0o700 so the parent directory keeps strangers out even if the socket inode is briefly loose. (Daemon dir, run dir, log dir — all three should be 0700 to be safe.)

Comment on lines +76 to +86
async function latestClaudeRc(fetch: FetchLike): Promise<string | undefined> {
const list = await getJson(fetch, RELEASES_URL, "claude-rc releases");
if (!Array.isArray(list)) throw new Error("claude-rc releases: unexpected response");
let best: string | undefined;
for (const r of list as Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>) {
if (!r.tag_name?.startsWith(TAG_PREFIX) || r.draft || r.prerelease) continue;
const v = r.tag_name.slice(TAG_PREFIX.length);
if (!best || compareVersions(v, best) > 0) best = v;
}
return best;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Update check only looks at the first 50 releases, no pagination. nsheaps/agents is a monorepo and may accumulate releases from many apps. If 50+ non-claude-rc-v* releases are published after the latest claude-rc-v*, this scanner never sees the latest claude-rc release and silently keeps reporting "up to date".

Options:

  • Paginate (page=1,2,…) until a matching tag is found or GitHub returns an empty page, with a sensible page cap (say 10) so a repo full of unrelated releases eventually bails.
  • Switch to the releases/tags/<prefix>… lookup via /releases/latest-by-tag-prefix-style client-side filter after fetching tag list — but GitHub still returns 30/page there, so pagination is required either way.

Not blocking today (there is no other app released from this repo yet), but worth fixing before this gets lost in a growing release stream.

Comment on lines +41 to +55
function checkEnv(v: unknown, where: string, errors: string[]): Record<string, string> {
if (v === undefined) return {};
if (!isObject(v)) {
errors.push(`${where}: env must be an object of string values`);
return {};
}
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) {
if (!ENV_KEY_RE.test(k)) errors.push(`${where}: env key "${k}" is not a valid variable name`);
else if (typeof val !== "string")
errors.push(`${where}: env value for "${k}" must be a string`);
else out[k] = val;
}
return out;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Env values accept arbitrary control chars. checkEnv only enforces typeof val === "string". A newline or NUL in a value makes systemd.ts:36 emit --setenv=KEY=multi\nline, which systemd-run will either reject with an opaque message or quote in a way that confuses later unit-file parsing. Same risk (less severe) in the plist renderer.

Suggested extra check inside the else-if branch:

Suggested change
function checkEnv(v: unknown, where: string, errors: string[]): Record<string, string> {
if (v === undefined) return {};
if (!isObject(v)) {
errors.push(`${where}: env must be an object of string values`);
return {};
}
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) {
if (!ENV_KEY_RE.test(k)) errors.push(`${where}: env key "${k}" is not a valid variable name`);
else if (typeof val !== "string")
errors.push(`${where}: env value for "${k}" must be a string`);
else out[k] = val;
}
return out;
}
else if (/[\x00-\x08\x0A-\x1F\x7F]/.test(val))
errors.push(`${where}: env value for "${k}" contains a control character`);
else out[k] = val;

(Tabs are intentionally allowed; everything else below 0x20 and DEL is rejected.)

Comment on lines +117 to +119
for pr in $(gh pr list --state open --search "chore: update claude-rc to" --json number --jq '.[].number' || true); do
gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}."
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — "Supersede older PRs" step closes PRs by title substring without author scope. gh pr list --state open --search "chore: update claude-rc to" matches any open PR whose title contains that substring, regardless of author. A human-opened correction PR (e.g., "chore: update claude-rc to pin SHA for CVE-…") would be force-closed by this workflow.

Scope the search to the automation account, e.g.:

Suggested change
for pr in $(gh pr list --state open --search "chore: update claude-rc to" --json number --jq '.[].number' || true); do
gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}."
done
for pr in $(gh pr list --state open --author "@me" --search "chore: update claude-rc to in:title" --json number --jq '.[].number' || true); do
gh pr close "$pr" --comment "Superseded by claude-rc ${VERSION}."
done

(or filter by the GitHub App slug if you prefer).

Comment on lines +207 to +219
case "start":
case "stop":
case "restart": {
const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name });
if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning);
if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error);
ctx.out(
`${cmd.cmd}${cmd.cmd === "stop" ? "ped" : cmd.cmd === "start" ? "ed" : "ed"} ${cmd.name ?? "all repos"}`.replace(
"stoped",
"stopped",
),
);
return EXIT.ok;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (nit) — past-tense built via string surgery. The "ped"/"ed" branch plus .replace("stoped","stopped") is harder to follow than a direct map.

Suggested change
case "start":
case "stop":
case "restart": {
const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name });
if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning);
if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error);
ctx.out(
`${cmd.cmd}${cmd.cmd === "stop" ? "ped" : cmd.cmd === "start" ? "ed" : "ed"} ${cmd.name ?? "all repos"}`.replace(
"stoped",
"stopped",
),
);
return EXIT.ok;
case "start":
case "stop":
case "restart": {
const r = await call(ctx, { cmd: cmd.cmd, name: cmd.name });
if (!r) return (ctx.err(NOT_RUNNING), EXIT.notRunning);
if (!r.ok) return (ctx.err(`claude-rc: ${r.error}`), EXIT.error);
const past = { start: "started", stop: "stopped", restart: "restarted" } as const;
ctx.out(`${past[cmd.cmd]} ${cmd.name ?? "all repos"}`);
return EXIT.ok;
}

Comment on lines +113 to +126
if (req.method === "POST" && p === "/api/repos") {
return edit(() => {
const path = body?.path;
if (typeof path !== "string" || !path) throw new Error("path is required");
if (!isAbsolute(path)) throw new Error(`path must be absolute (got "${path}")`);
if (!existsSync(path) || !statSync(path).isDirectory())
throw new Error(`path does not exist or is not a directory: ${path}`);
addRepo(opts.paths.configPath, {
name: typeof body.name === "string" && body.name ? body.name : basename(path),
path,
...(typeof body.spawn === "string" ? { spawn: body.spawn as never } : {}),
});
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Default name from basename(path) can fail NAME_RE at write time. If a user drops /home/alice/My Project into the "Add repo" form without setting a name, basename(path) is My Project, which fails [A-Za-z0-9][A-Za-z0-9._-]* in parseConfig. The user sees "name must match /…/" without obvious guidance that the problem is their directory name.

Either sanitize the derived name (replace invalid chars with _ and lowercase, say) or surface the derived value in the error so the user knows what they're being asked to override:

const derived = typeof body.name === "string" && body.name ? body.name : basename(path);
addRepo(opts.paths.configPath, { name: derived, path, ... });

Same consideration applies to apps/claude-rc-cli/src/args.ts:60.

- Bind the IPC socket under umask 077 and create daemon/log/run dirs 0700.
- ensureTrusted re-reads and redoes if ~/.claude.json changed mid-update;
  spec wording no longer overclaims atomicity.
- Update check pages through GitHub releases (bounded to 5 pages).
- Reject control characters in env values.
- Derive a valid repo name from the directory (e.g. "My Project").
- Release workflow only supersedes PRs opened by its own account.
- Replace string surgery for past-tense output with a map.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyfkztURg1jAXQWSCpEa2p

nsheaps commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

Addressed all seven findings from the review in 732972d:

  • IPC socket race (P1): the socket is now bound under umask 077, and the daemon, log and run dirs are created 0700.
  • ensureTrusted read-modify-write (P1): it now re-reads and redoes if ~/.claude.json changed between the read and the rename (bounded retries, test with a simulated concurrent writer). A write landing in the last microseconds before the rename can still be lost, since we can't lock a file Claude owns. The spec no longer claims the RMW is atomic.
  • Release pagination (P2): the update check pages through releases, capped at 5 pages, with tests.
  • Derived repo name (P2): deriveRepoName is used by both the web UI and the CLI, so My Project becomes My-Project.
  • Release workflow (P2): it now closes only PRs authored by its own account (--author "@me", title-scoped).
  • Env control characters (P2): rejected, and tabs/unicode are still allowed.
  • Past-tense nit: replaced by a map.

format, lint, build and tests pass locally (core 330, daemon 67, CLI 78).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants