claude-nomad 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.0](https://github.com/funkadelic/claude-nomad/compare/v0.17.2...v0.18.0) (2026-05-22)
4
+
5
+
6
+ ### Added
7
+
8
+ * **init:** auto-disable GitHub Actions on private mirror ([#94](https://github.com/funkadelic/claude-nomad/issues/94)) ([aee4736](https://github.com/funkadelic/claude-nomad/commit/aee47365f504c4700a619a2828c6fca8c18b1868))
9
+
10
+ ## [0.17.2](https://github.com/funkadelic/claude-nomad/compare/v0.17.1...v0.17.2) (2026-05-22)
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **ci:** retry smoke-test install for registry propagation ([#92](https://github.com/funkadelic/claude-nomad/issues/92)) ([1ae7576](https://github.com/funkadelic/claude-nomad/commit/1ae75760f674e93be1fadc2b927ee4dd03f9d346))
16
+
3
17
  ## [0.17.1](https://github.com/funkadelic/claude-nomad/compare/v0.17.0...v0.17.1) (2026-05-21)
4
18
 
5
19
 
package/README.md CHANGED
@@ -30,6 +30,9 @@ Two things it does that ad-hoc dotfiles syncing can't:
30
30
  - **Getting started**
31
31
  - [Requirements](#requirements)
32
32
  - [Setup](#setup)
33
+ - [Privacy by default](#privacy-by-default)
34
+ - [Bootstrap](#bootstrap)
35
+ - [Initialize the repo layout](#initialize-the-repo-layout)
33
36
  - [Migrating an existing ~/.claude/](#migrating-an-existing-claude)
34
37
  - [Upgrading the tool](#upgrading-the-tool)
35
38
  - **Reference**
@@ -51,7 +54,7 @@ npm i -g claude-nomad
51
54
 
52
55
  ```bash
53
56
  # Clone your private mirror so nomad has a repo to sync into.
54
- git clone git@github.com:you/claude-nomad.git ~/claude-nomad
57
+ git clone git@github.com:<your-username>/claude-nomad.git ~/claude-nomad
55
58
 
56
59
  # Add to ~/.zshrc or ~/.bashrc:
57
60
  export NOMAD_HOST=<your-host-label>
@@ -76,7 +79,7 @@ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/
76
79
  claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The tool's source and your config end up coexisting in one working tree on each host.
77
80
 
78
81
  ```
79
- public funkadelic/claude-nomad your private you/claude-nomad
82
+ public funkadelic/claude-nomad your private <your-username>/claude-nomad
80
83
  ├── src/ (the CLI) ├── src/ (copy of the CLI)
81
84
  ├── package.json ├── package.json
82
85
  └── ... ├── ...
@@ -101,7 +104,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
101
104
  ```
102
105
  ~/claude-nomad/
103
106
  ├── src/ # the CLI (came from the public tool repo)
104
- ├── scripts/ # tool helpers (update.sh; plus any one-shot scripts you add)
107
+ ├── scripts/ # helper scripts you add
105
108
  ├── shared/ # synced to every machine
106
109
  │ ├── CLAUDE.md
107
110
  │ ├── settings.base.json # baseline settings
@@ -209,25 +212,36 @@ Read these before adopting so you opt in with eyes open.
209
212
 
210
213
  **Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into a fresh private repo, not a fork.
211
214
 
215
+ ### Privacy by default
216
+
217
+ Your private mirror has two layers of defense against leaking transcripts via CI, both applied automatically:
218
+
219
+ 1. Every workflow under `.github/workflows/` is gated on `${{ !github.event.repository.private }}`, so they skip on private repos and only run on public ones.
220
+ 2. `nomad init` calls `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` on first run, turning Actions off at the repo level. Requires `gh` CLI authed; if missing or unauthed, init logs a manual fallback tip and continues.
221
+
222
+ Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already enforces an Actions policy upstream).
223
+
212
224
  > [!WARNING]
213
- > Keep the mirror private. CI workflows under `.github/workflows/` are gated on `${{ !github.event.repository.private }}`, so they skip on any private repo and run only on public ones. Flipping your mirror to public will start firing CI on every `nomad push` against `main`, and your session transcripts (which include conversation content) become world-readable.
225
+ > If you ever flip the mirror to public, both protections evaporate: CI starts firing on every `nomad push` against `main`, and your session transcripts (which include conversation content) become world-readable. Keep it private.
214
226
 
215
- Bootstrap (steps 1-2 are once-ever across all hosts; step 3 repeats per host):
227
+ ### Bootstrap
228
+
229
+ Steps 1-2 are once-ever across all hosts; step 3 repeats per host:
216
230
 
217
231
  ```bash
218
232
  # 1. Create the private repo (or use the GitHub UI). Once, ever.
219
- gh repo create you/claude-nomad --private
233
+ gh repo create <your-username>/claude-nomad --private
220
234
 
221
235
  # 2. Mirror the public tool into it. This severs the fork relationship,
222
236
  # so your repo is independent of upstream. Once, ever.
223
237
  git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/cn.git
224
238
  cd /tmp/cn.git
225
- git push --mirror git@github.com:you/claude-nomad.git
239
+ git push --mirror git@github.com:<your-username>/claude-nomad.git
226
240
  cd .. && rm -rf /tmp/cn.git
227
241
 
228
242
  # 3. Install the CLI globally and clone your private copy. Repeat on every host.
229
243
  npm i -g claude-nomad
230
- git clone git@github.com:you/claude-nomad.git ~/claude-nomad
244
+ git clone git@github.com:<your-username>/claude-nomad.git ~/claude-nomad
231
245
  ```
232
246
 
233
247
  `npm i -g claude-nomad` puts a `nomad` binary on your PATH. The bin shim is the existing `src/nomad.ts` entrypoint resolved through tsx (a runtime dependency); no compile step. The npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes; npm only blocks the install when `engine-strict=true` is configured.
@@ -242,7 +256,9 @@ export NOMAD_HOST=<your-host-label> # any short, stable label; nomad reads
242
256
 
243
257
  `NOMAD_HOST` overrides `os.hostname()`, which returns noisy values like `WINDOWS-I5NT6OH` on WSL or `<name>.local` on macOS. Pick a clean label per machine (e.g., `wsl-laptop`, `macbook`, `homelab-nuc`). `nomad doctor` reports the resolved host so you can confirm.
244
258
 
245
- Initialize the repo layout (first host only; subsequent hosts just clone and `nomad pull`). Pick one:
259
+ ### Initialize the repo layout
260
+
261
+ First host only; subsequent hosts just clone and `nomad pull`. Both forms below auto-disable Actions on a detected private GitHub mirror as described in [Privacy by default](#privacy-by-default). Pick one:
246
262
 
247
263
  ```bash
248
264
  # Fresh start: scaffold an empty shared/, hosts/, path-map.json skeleton.
@@ -252,6 +268,9 @@ nomad init
252
268
  # starting point. Stages shared/ and writes hosts/<NOMAD_HOST>.json from
253
269
  # your current ~/.claude/settings.json. Does NOT touch the originals.
254
270
  nomad init --snapshot
271
+
272
+ # Either form accepts --keep-actions to skip the auto-disable.
273
+ nomad init --keep-actions
255
274
  ```
256
275
 
257
276
  `nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a safe no-op (it errors out naming the offender). `nomad pull` against an unscaffolded repo fails fast with `FATAL: repo not initialized; run 'nomad init' to scaffold` instead of silently leaving a half-state.
@@ -324,8 +343,6 @@ One-time setup if you're running a fork layout and don't have the `upstream` rem
324
343
  git remote add upstream git@github.com:funkadelic/claude-nomad.git
325
344
  ```
326
345
 
327
- `npm run update` still exists as a legacy shim that shells out to `scripts/update.sh`; prefer `nomad update` for new invocations.
328
-
329
346
  To pin to a specific release (`vX.Y.Z`, tagged by release-please) instead of tracking `main`, fetch tags from the public repo and check out the tag (detached HEAD). On vanilla topology that's `origin`; on fork topology that's `upstream` (the private mirror at `origin` does not accumulate upstream release tags). Example: `git fetch upstream --tags && git switch --detach vX.Y.Z` (substitute `origin` for vanilla; use `git checkout vX.Y.Z` on older Git).
330
347
 
331
348
  If you installed an earlier version via `./install.sh` and a shell alias (the pre-npm path), your existing alias keeps working unchanged. Run `npm i -g claude-nomad` whenever you're ready to switch to the global binary, confirm `nomad --version` resolves to the npm install (`which nomad` should point under your npm prefix's `bin/`), then delete the alias line from your shell rc.
@@ -334,8 +351,9 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
334
351
 
335
352
  | Command | Description |
336
353
  | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
337
- | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. |
338
- | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. |
354
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
355
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
356
+ | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
339
357
  | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths. FATAL if scaffold missing. |
340
358
  | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
341
359
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -17,7 +17,7 @@ import {
17
17
  // prettier-ignore
18
18
  import { CLAUDE_HOME, HOST, KNOWN_SETTINGS_KEYS, NEVER_SYNC, REPO_HOME, SHARED_LINKS, type PathMap } from './config.ts';
19
19
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
20
- import { classifyRepoState, reasonForPartial } from './init.ts';
20
+ import { classifyRepoState, reasonForPartial } from './init.classify.ts';
21
21
  import { findGitlinks } from './push-checks.ts';
22
22
  import { encodePath, gitStatusPorcelainZ, readJson } from './utils.ts';
23
23
 
@@ -0,0 +1,125 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
2
+
3
+ /**
4
+ * GitHub repo owner/name pair parsed from a remote URL. Used by
5
+ * `cmdInit`'s auto-disable hook and `nomad doctor`'s mirror-Actions check.
6
+ */
7
+ export type GhRepoRef = { owner: string; repo: string };
8
+
9
+ /**
10
+ * Reason `ghAuthStatus` returned without success. Distinguishes the two
11
+ * actionable failure modes so callers can print useful tips.
12
+ */
13
+ export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed';
14
+
15
+ /**
16
+ * Injectable subprocess runner so tests can mock without `vi.doMock` and
17
+ * without touching `execFileSync` on the real shell. Default binds to
18
+ * `child_process.execFileSync` with the same signature.
19
+ */
20
+ export type SpawnSyncFn = (
21
+ bin: string,
22
+ args: readonly string[],
23
+ opts?: ExecFileSyncOptions,
24
+ ) => Buffer | string;
25
+
26
+ /**
27
+ * Maximum time in milliseconds to wait for a `gh` CLI subprocess. Prevents
28
+ * `nomad init` from hanging indefinitely on a slow or captive-portal network;
29
+ * `execFileSync` throws `ETIMEDOUT` on expiry, which the callers' try/catch
30
+ * blocks already handle as a silent-skip.
31
+ */
32
+ const GH_TIMEOUT_MS = 5_000;
33
+
34
+ /**
35
+ * Parse a git remote URL into `{ owner, repo }` when it points at GitHub.
36
+ * Returns `null` for any non-GitHub URL (other forge, local path, malformed)
37
+ * so the caller silently skips rather than failing init. Strips a trailing
38
+ * `.git` if present.
39
+ */
40
+ export function parseGitHubRemote(remoteUrl: string): GhRepoRef | null {
41
+ const normalized = remoteUrl.trim().replace(/\/$/, '');
42
+ const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(normalized);
43
+ if (m === null) return null;
44
+ return { owner: m[1], repo: m[2] };
45
+ }
46
+
47
+ /**
48
+ * Check `gh` CLI availability and auth status in one call. Returns null on
49
+ * success or a structured reason string. `gh auth status` exits 0 when the
50
+ * user is authed against github.com and non-zero otherwise; ENOENT signals
51
+ * the binary itself is missing.
52
+ */
53
+ export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReason | null {
54
+ try {
55
+ run('gh', ['auth', 'status'], {
56
+ stdio: ['ignore', 'ignore', 'ignore'],
57
+ timeout: GH_TIMEOUT_MS,
58
+ });
59
+ return null;
60
+ } catch (err) {
61
+ const e = err as { code?: string };
62
+ if (e.code === 'ENOENT') return 'gh-not-installed';
63
+ return 'gh-not-authed';
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Fetch the `isPrivate` flag for a repo. Throws on subprocess or JSON
69
+ * failure; callers wrap with try/catch and treat as silent-skip.
70
+ */
71
+ export function isRepoPrivate(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): boolean {
72
+ const out = run('gh', ['repo', 'view', `${ref.owner}/${ref.repo}`, '--json', 'isPrivate'], {
73
+ stdio: ['ignore', 'pipe', 'ignore'],
74
+ timeout: GH_TIMEOUT_MS,
75
+ }).toString();
76
+ const parsed = JSON.parse(out) as { isPrivate?: unknown };
77
+ return parsed.isPrivate === true;
78
+ }
79
+
80
+ /**
81
+ * Fetch the `enabled` field of the repo's Actions permissions. Throws on
82
+ * subprocess failure; callers wrap with try/catch.
83
+ */
84
+ export function isActionsEnabled(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): boolean {
85
+ const out = run(
86
+ 'gh',
87
+ ['api', `repos/${ref.owner}/${ref.repo}/actions/permissions`, '--jq', '.enabled'],
88
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: GH_TIMEOUT_MS },
89
+ )
90
+ .toString()
91
+ .trim();
92
+ return out === 'true';
93
+ }
94
+
95
+ /**
96
+ * Disable GitHub Actions on a repo. Idempotent on GitHub's side: re-disabling
97
+ * an already-disabled repo returns success. Throws on subprocess failure.
98
+ */
99
+ export function disableActions(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): void {
100
+ run(
101
+ 'gh',
102
+ [
103
+ 'api',
104
+ '-X',
105
+ 'PUT',
106
+ `repos/${ref.owner}/${ref.repo}/actions/permissions`,
107
+ '-F',
108
+ 'enabled=false',
109
+ ],
110
+ { stdio: ['ignore', 'ignore', 'pipe'], timeout: GH_TIMEOUT_MS },
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Read the `origin` remote URL for a git working tree at `cwd`. Throws on
116
+ * any failure (no remote, not a git repo); callers treat as silent-skip.
117
+ */
118
+ export function readOriginRemote(cwd: string, run: SpawnSyncFn = execFileSync): string {
119
+ return run('git', ['remote', 'get-url', 'origin'], {
120
+ cwd,
121
+ stdio: ['ignore', 'pipe', 'ignore'],
122
+ })
123
+ .toString()
124
+ .trim();
125
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { type PathMap } from './config.ts';
5
+ import { readJson } from './utils.ts';
6
+
7
+ /**
8
+ * Read-only health classifier for `cmdDoctor`'s `repo state:` header.
9
+ * Inspects three signals at the given `repoHome`: `shared/settings.base.json`
10
+ * presence, `path-map.json.projects` having at least one entry, and
11
+ * `hosts/<host>.json` presence.
12
+ *
13
+ * Returns `'empty'` when the base is missing AND the path-map has no entries
14
+ * (either missing or `projects` is empty); `'populated'` when all three
15
+ * signals are positive; `'partial'` for anything in between. Malformed
16
+ * `path-map.json` is treated as zero entries rather than thrown, so a doctor
17
+ * run against a corrupted scaffold still produces a classification line.
18
+ *
19
+ * The `host` parameter is passed explicitly (rather than read from the
20
+ * imported `HOST` constant) so the test fixture can drive multiple host
21
+ * scenarios without mutating module-level state via `vi.resetModules()`.
22
+ */
23
+ export function classifyRepoState(
24
+ repoHome: string,
25
+ host: string,
26
+ ): 'empty' | 'partial' | 'populated' {
27
+ const basePath = join(repoHome, 'shared', 'settings.base.json');
28
+ const mapPath = join(repoHome, 'path-map.json');
29
+ const hostPath = join(repoHome, 'hosts', `${host}.json`);
30
+
31
+ const hasBase = existsSync(basePath);
32
+ const hasMap = existsSync(mapPath);
33
+ const hasHost = existsSync(hostPath);
34
+
35
+ let mapEntryCount = 0;
36
+ if (hasMap) {
37
+ try {
38
+ const map = readJson<PathMap>(mapPath);
39
+ mapEntryCount = Object.keys(map.projects).length;
40
+ } catch {
41
+ // Malformed JSON: treat as zero entries, do NOT throw. The doctor's
42
+ // own JSON-parse FAIL line will surface the malformed file separately.
43
+ mapEntryCount = 0;
44
+ }
45
+ }
46
+
47
+ if (!hasBase && mapEntryCount === 0) return 'empty';
48
+ if (hasBase && mapEntryCount > 0 && hasHost) return 'populated';
49
+ return 'partial';
50
+ }
51
+
52
+ /**
53
+ * Suffix that follows `repo state: WARN partial` per the fixed priority
54
+ * order. First matching condition wins, exactly one suffix per line.
55
+ * Inspects the same on-disk signals `classifyRepoState` reads (base file,
56
+ * `path-map.json` + its `.projects` entry count, `hosts/<host>.json`), but
57
+ * explicitly distinguishes "path-map missing" from "path-map present but
58
+ * empty" because users debug differently for each.
59
+ *
60
+ * Lives alongside `classifyRepoState` so the suffix rules and the classifier
61
+ * stay co-located: changes to one almost always require updating the other.
62
+ * Returns the string with a leading `- ` separator so the caller can
63
+ * concatenate directly without re-deciding the separator.
64
+ */
65
+ export function reasonForPartial(repoHome: string, host: string): string {
66
+ const basePath = join(repoHome, 'shared', 'settings.base.json');
67
+ const mapPath = join(repoHome, 'path-map.json');
68
+ const hostPath = join(repoHome, 'hosts', `${host}.json`);
69
+ if (!existsSync(basePath)) return '- shared/settings.base.json missing';
70
+ if (!existsSync(mapPath)) return '- path-map.json missing';
71
+ let mapEntryCount: number;
72
+ try {
73
+ const map = readJson<PathMap>(mapPath);
74
+ mapEntryCount = Object.keys(map.projects).length;
75
+ } catch {
76
+ // Malformed JSON: treat as zero entries. Doctor's own JSON-parse FAIL
77
+ // line surfaces the malformed file separately.
78
+ mapEntryCount = 0;
79
+ }
80
+ if (mapEntryCount === 0) return '- path-map.json.projects has no entries';
81
+ if (!existsSync(hostPath)) return `- hosts/${host}.json missing`;
82
+ // Defensive fallback: classifyRepoState returned 'partial' for a reason
83
+ // not captured by the four signals above. Should be unreachable in
84
+ // practice because the priority order is exhaustive against the
85
+ // classifier's definition of populated.
86
+ return '- partial state (unknown gap)';
87
+ }
package/src/init.ts CHANGED
@@ -2,8 +2,17 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { CLAUDE_HOME, type PathMap, REPO_HOME } from './config.ts';
5
+ import {
6
+ disableActions,
7
+ ghAuthStatus,
8
+ isActionsEnabled,
9
+ isRepoPrivate,
10
+ parseGitHubRemote,
11
+ readOriginRemote,
12
+ type SpawnSyncFn,
13
+ } from './gh-actions.ts';
5
14
  import { snapshotIntoShared } from './init.snapshot.ts';
6
- import { die, log, readJson, writeJsonAtomic } from './utils.ts';
15
+ import { die, log, writeJsonAtomic } from './utils.ts';
7
16
 
8
17
  /**
9
18
  * The HTML comment line that anchors `shared/CLAUDE.md` on a fresh scaffold.
@@ -59,8 +68,11 @@ function preflightConflict(repoHome: string): string | null {
59
68
  * identical to plain init; a bare `shared/` dir is enough to refuse since
60
69
  * partial state is unsafe to merge with.
61
70
  */
62
- export function cmdInit(opts: { snapshot?: boolean } = {}): void {
71
+ export function cmdInit(
72
+ opts: { snapshot?: boolean; keepActions?: boolean; run?: SpawnSyncFn } = {},
73
+ ): void {
63
74
  const snapshot = opts.snapshot === true;
75
+ const keepActions = opts.keepActions === true;
64
76
 
65
77
  const conflict = preflightConflict(REPO_HOME);
66
78
  if (conflict !== null) {
@@ -104,87 +116,78 @@ export function cmdInit(opts: { snapshot?: boolean } = {}): void {
104
116
  log('~/.claude/ originals were NOT removed.');
105
117
  }
106
118
 
119
+ if (!keepActions) {
120
+ maybeDisableMirrorActions(REPO_HOME, opts.run);
121
+ }
122
+
107
123
  log('init complete');
108
124
  }
109
125
 
110
126
  /**
111
- * Read-only health classifier for `cmdDoctor`'s `repo state:` header.
112
- * Inspects three signals at the given `repoHome`: `shared/settings.base.json`
113
- * presence, `path-map.json.projects` having at least one entry, and
114
- * `hosts/<host>.json` presence.
115
- *
116
- * Returns `'empty'` when the base is missing AND the path-map has no entries
117
- * (either missing or `projects` is empty); `'populated'` when all three
118
- * signals are positive; `'partial'` for anything in between. Malformed
119
- * `path-map.json` is treated as zero entries rather than thrown, so a doctor
120
- * run against a corrupted scaffold still produces a classification line.
127
+ * Best-effort hook that disables GitHub Actions on the user's private mirror
128
+ * after a fresh `nomad init`. The private mirror is a settings store, not a
129
+ * CI target; leaving Actions enabled there causes the mirror-pushed workflows
130
+ * (release-please, npm-publish, etc.) to fire on every `nomad push`, which is
131
+ * pure noise.
121
132
  *
122
- * The `host` parameter is passed explicitly (rather than read from the
123
- * imported `HOST` constant) so the test fixture can drive multiple host
124
- * scenarios without mutating module-level state via `vi.resetModules()`.
133
+ * Silently no-ops when: the repo is not a git repo, the origin remote is not
134
+ * GitHub, the origin is public (not a private mirror), `gh` CLI is missing,
135
+ * or `gh` is not authed. Prints a tip on the last two so the user can finish
136
+ * the step manually. Suppress entirely with `nomad init --keep-actions`.
125
137
  */
126
- export function classifyRepoState(
127
- repoHome: string,
128
- host: string,
129
- ): 'empty' | 'partial' | 'populated' {
130
- const basePath = join(repoHome, 'shared', 'settings.base.json');
131
- const mapPath = join(repoHome, 'path-map.json');
132
- const hostPath = join(repoHome, 'hosts', `${host}.json`);
133
-
134
- const hasBase = existsSync(basePath);
135
- const hasMap = existsSync(mapPath);
136
- const hasHost = existsSync(hostPath);
137
-
138
- let mapEntryCount = 0;
139
- if (hasMap) {
140
- try {
141
- const map = readJson<PathMap>(mapPath);
142
- mapEntryCount = Object.keys(map.projects).length;
143
- } catch {
144
- // Malformed JSON: treat as zero entries, do NOT throw. The doctor's
145
- // own JSON-parse FAIL line will surface the malformed file separately.
146
- mapEntryCount = 0;
147
- }
138
+ function maybeDisableMirrorActions(repoHome: string, run?: SpawnSyncFn): void {
139
+ let remote: string;
140
+ try {
141
+ remote = readOriginRemote(repoHome, run);
142
+ } catch {
143
+ return;
148
144
  }
145
+ const ref = parseGitHubRemote(remote);
146
+ if (ref === null) return;
149
147
 
150
- if (!hasBase && mapEntryCount === 0) return 'empty';
151
- if (hasBase && mapEntryCount > 0 && hasHost) return 'populated';
152
- return 'partial';
153
- }
148
+ const ghStatus = ghAuthStatus(run);
149
+ if (ghStatus === 'gh-not-installed') {
150
+ log(
151
+ `tip: install gh CLI and run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' to disable Actions on your private mirror.`,
152
+ );
153
+ return;
154
+ }
155
+ if (ghStatus === 'gh-not-authed') {
156
+ log(
157
+ `tip: run 'gh auth login' then 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' to disable Actions on your private mirror.`,
158
+ );
159
+ return;
160
+ }
161
+
162
+ let isPrivate: boolean;
163
+ try {
164
+ isPrivate = isRepoPrivate(ref, run);
165
+ } catch {
166
+ log(
167
+ `could not determine privacy for ${ref.owner}/${ref.repo}; run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' manually if it is private.`,
168
+ );
169
+ return;
170
+ }
171
+ if (!isPrivate) return;
172
+
173
+ let alreadyDisabled = false;
174
+ try {
175
+ alreadyDisabled = !isActionsEnabled(ref, run);
176
+ } catch {
177
+ // Treat as enabled and attempt the disable; the API call itself is
178
+ // idempotent so this is safe.
179
+ }
180
+ if (alreadyDisabled) {
181
+ log(`actions already disabled on ${ref.owner}/${ref.repo}`);
182
+ return;
183
+ }
154
184
 
155
- /**
156
- * Suffix that follows `repo state: WARN partial` per the fixed priority
157
- * order. First matching condition wins, exactly one suffix per line.
158
- * Inspects the same on-disk signals `classifyRepoState` reads (base file,
159
- * `path-map.json` + its `.projects` entry count, `hosts/<host>.json`), but
160
- * explicitly distinguishes "path-map missing" from "path-map present but
161
- * empty" because users debug differently for each.
162
- *
163
- * Lives alongside `classifyRepoState` so the suffix rules and the classifier
164
- * stay co-located: changes to one almost always require updating the other.
165
- * Returns the string with a leading `- ` separator so the caller can
166
- * concatenate directly without re-deciding the separator.
167
- */
168
- export function reasonForPartial(repoHome: string, host: string): string {
169
- const basePath = join(repoHome, 'shared', 'settings.base.json');
170
- const mapPath = join(repoHome, 'path-map.json');
171
- const hostPath = join(repoHome, 'hosts', `${host}.json`);
172
- if (!existsSync(basePath)) return '- shared/settings.base.json missing';
173
- if (!existsSync(mapPath)) return '- path-map.json missing';
174
- let mapEntryCount: number;
175
185
  try {
176
- const map = readJson<PathMap>(mapPath);
177
- mapEntryCount = Object.keys(map.projects).length;
186
+ disableActions(ref, run);
187
+ log(`disabled GitHub Actions on private mirror ${ref.owner}/${ref.repo}`);
178
188
  } catch {
179
- // Malformed JSON: treat as zero entries. Doctor's own JSON-parse FAIL
180
- // line surfaces the malformed file separately.
181
- mapEntryCount = 0;
189
+ log(
190
+ `could not auto-disable Actions on ${ref.owner}/${ref.repo}; run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' manually.`,
191
+ );
182
192
  }
183
- if (mapEntryCount === 0) return '- path-map.json.projects has no entries';
184
- if (!existsSync(hostPath)) return `- hosts/${host}.json missing`;
185
- // Defensive fallback: classifyRepoState returned 'partial' for a reason
186
- // not captured by the four signals above. Should be unreachable in
187
- // practice because the priority order is exhaustive against the
188
- // classifier's definition of populated.
189
- return '- partial state (unknown gap)';
190
193
  }
package/src/nomad.ts CHANGED
@@ -57,7 +57,8 @@ const DEFAULT_HELP = [
57
57
  ' No git pull, no lock acquired.',
58
58
  '',
59
59
  ' init Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).',
60
- ' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
60
+ ' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
61
+ ' --keep-actions Skip auto-disabling GitHub Actions on the private mirror.',
61
62
  '',
62
63
  ' doctor Read-only health check (symlinks, host file, path-map,',
63
64
  ' gitleaks, gitlinks).',
@@ -128,20 +129,28 @@ try {
128
129
  }
129
130
  break;
130
131
  }
131
- case 'init':
132
- // Two valid forms: `nomad init` (empty scaffold) and
133
- // `nomad init --snapshot` (overlay user's current ~/.claude/ into
134
- // shared/). Anything else (unknown flag, extra positional arg, two
135
- // flags) hits the same usage-error pattern as `doctor --resume-cmd`.
136
- if (process.argv[3] === undefined) {
137
- cmdInit();
138
- } else if (process.argv[3] === '--snapshot' && process.argv[4] === undefined) {
139
- cmdInit({ snapshot: true });
140
- } else {
141
- console.error('usage: nomad init [--snapshot]');
132
+ case 'init': {
133
+ // Set-based parse so flag order does not matter and duplicates are
134
+ // rejected. Unknown flags hit the same usage-error pattern as other
135
+ // subcommands.
136
+ const known = new Set(['--snapshot', '--keep-actions']);
137
+ const seen = new Set<string>();
138
+ let argvOk = true;
139
+ for (let i = 3; i < process.argv.length; i++) {
140
+ const flag = process.argv[i];
141
+ if (!known.has(flag) || seen.has(flag)) {
142
+ argvOk = false;
143
+ break;
144
+ }
145
+ seen.add(flag);
146
+ }
147
+ if (!argvOk) {
148
+ console.error('usage: nomad init [--snapshot] [--keep-actions]');
142
149
  process.exit(1);
143
150
  }
151
+ cmdInit({ snapshot: seen.has('--snapshot'), keepActions: seen.has('--keep-actions') });
144
152
  break;
153
+ }
145
154
  case 'diff':
146
155
  // Offline, lockless preview against local repo state. No git pull, no
147
156
  // lock acquisition. Reject any argv after `diff` since this slice