claude-nomad 0.25.4 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.4",
3
+ "version": "0.26.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": [
@@ -42,6 +42,7 @@
42
42
  "typecheck": "tsc --noEmit",
43
43
  "lint": "eslint .",
44
44
  "lint:fix": "eslint . --fix",
45
+ "lint:md": "markdownlint-cli2",
45
46
  "format": "prettier --write .",
46
47
  "format:check": "prettier --check .",
47
48
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && node scripts/verify-tarball.cjs",
@@ -52,7 +53,11 @@
52
53
  "eslint --fix",
53
54
  "prettier --write"
54
55
  ],
55
- "*.{js,mjs,cjs,json,md}": [
56
+ "*.{js,mjs,cjs,json}": [
57
+ "prettier --write"
58
+ ],
59
+ "*.md": [
60
+ "markdownlint-cli2 --fix",
56
61
  "prettier --write"
57
62
  ]
58
63
  },
@@ -64,9 +69,11 @@
64
69
  "@vitest/coverage-v8": "^4.1.6",
65
70
  "eslint": "^10.4.0",
66
71
  "eslint-config-prettier": "^10.1.8",
72
+ "eslint-plugin-sonarjs": "^4.0.3",
67
73
  "globals": "^17.6.0",
68
74
  "husky": "^9.1.7",
69
75
  "lint-staged": "^17.0.5",
76
+ "markdownlint-cli2": "^0.22.1",
70
77
  "prettier": "^3.8.3",
71
78
  "typescript": "^6.0.3",
72
79
  "typescript-eslint": "^8.59.4",
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { join } from 'node:path';
16
16
 
17
- import { green, red, okGlyph, failGlyph } from './color.ts';
17
+ import { green, red, dim, okGlyph, failGlyph } from './color.ts';
18
18
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
19
  import { CLAUDE_HOME } from './config.ts';
20
20
  import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
@@ -47,16 +47,16 @@ function reportSessionFindings(
47
47
  ): void {
48
48
  for (const [sid, counts] of bySession) {
49
49
  const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
- addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
50
+ addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
51
51
  const logical = logicalBySession.get(sid);
52
52
  /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
53
53
  if (logical !== undefined) {
54
54
  addItem(
55
55
  section,
56
- ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
56
+ ` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
57
57
  );
58
58
  }
59
- addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
59
+ addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
60
60
  }
61
61
  process.exitCode = 1;
62
62
  }
@@ -70,7 +70,7 @@ function reportSessionFindings(
70
70
  */
71
71
  function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
72
72
  for (const f of other) {
73
- addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
73
+ addItem(section, `${red(failGlyph)} ${red(f.RuleID)} leak in ${f.File}`);
74
74
  }
75
75
  process.exitCode = 1;
76
76
  }
@@ -111,6 +111,29 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
111
111
  return logicalBySession;
112
112
  }
113
113
 
114
+ /**
115
+ * Emit a deduplicated description legend in the footer: one `[rule-id]:
116
+ * description` row per distinct RuleID across all findings, set off by a blank
117
+ * line before and after. Sourced from the `Description` gitleaks bakes into
118
+ * each finding, so it needs no network; rules whose description is absent
119
+ * (older gitleaks, custom rules) are skipped, and the whole block (including
120
+ * the surrounding blanks) is omitted when no descriptions are available. The
121
+ * legend lives in the footer so a rule hit across many files or sessions
122
+ * (e.g. `sonar-api-token`) is explained once, not per occurrence.
123
+ */
124
+ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
125
+ const descByRule = new Map<string, string>();
126
+ for (const f of findings) {
127
+ if (f.Description && !descByRule.has(f.RuleID)) descByRule.set(f.RuleID, f.Description);
128
+ }
129
+ if (descByRule.size === 0) return;
130
+ addItem(section, '');
131
+ for (const [rule, desc] of descByRule) {
132
+ addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
133
+ }
134
+ addItem(section, '');
135
+ }
136
+
114
137
  /**
115
138
  * Scan the staged temp tree through the shared `scanStagedTree` and emit the
116
139
  * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
@@ -155,4 +178,5 @@ export function scanAndReport(
155
178
  if (bySession.size > 0) {
156
179
  reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
157
180
  }
181
+ emitDescriptionLegend(section, findings);
158
182
  }
@@ -13,7 +13,7 @@ import { encodePath } from './utils.json.ts';
13
13
  * `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
14
14
  */
15
15
 
16
- /** Emits the mapped-projects header for the current host and one line per mapped project. */
16
+ /** Emits the mapped-projects header for the current host and one indented child line per mapped project. */
17
17
  function reportMappedProjects(section: DoctorSection, map: PathMap): void {
18
18
  const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
19
19
  addItem(
@@ -21,7 +21,7 @@ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
21
21
  `${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
22
22
  );
23
23
  for (const [name, hosts] of mapped) {
24
- addItem(section, `${dim(infoGlyph)} ${name} -> ${blue(hosts[HOST])}`);
24
+ addItem(section, ` ${name} -> ${blue(hosts[HOST])}`);
25
25
  }
26
26
  }
27
27
 
@@ -43,7 +43,7 @@ export function isOverrideActive(): boolean {
43
43
  * Pushes the host identity (info) and the two key path lines (repo and
44
44
  * claude-home) with gutter glyphs. Path presence is reported via warnGlyph
45
45
  * (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
46
- * decorate the Host header with `✘`. The authoritative empty-repo FAIL is
46
+ * decorate the Host header with a fail glyph. The authoritative empty-repo FAIL is
47
47
  * owned by reportRepoState; these two lines remain informational and do
48
48
  * NOT mutate process.exitCode.
49
49
  */
@@ -83,51 +83,96 @@ export function reportRepoState(section: DoctorSection): void {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * True when the repo has a `shared/<name>` source for this link. `applySharedLinks`
88
+ * only creates a symlink when this source exists, so when it does NOT, an absent
89
+ * or dangling link in `~/.claude/` is expected (nothing to sync), not a problem to
90
+ * fix. Doctor uses this to downgrade those rows from a warn to an info note.
91
+ */
92
+ function repoHasSharedSource(name: string): boolean {
93
+ return existsSync(join(REPO_HOME, 'shared', name));
94
+ }
95
+
96
+ /**
97
+ * Resolve the display item and optional exit-code side-effect for a single
98
+ * shared-link path. Returns `{ line, fail }` where `fail` true means the
99
+ * caller should set `process.exitCode = 1`.
100
+ *
101
+ * Extracted from `reportSharedLinks` to reduce cognitive complexity: the lstat
102
+ * try/catch and the inner symlink-target try/catch each count against the
103
+ * parent function's score.
104
+ */
105
+ function classifySharedLink(name: string, p: string): { line: string; fail: boolean } {
106
+ let stat;
107
+ try {
108
+ stat = lstatSync(p);
109
+ } catch (err) {
110
+ const code = (err as NodeJS.ErrnoException).code;
111
+ if (code === 'ENOENT') {
112
+ return repoHasSharedSource(name)
113
+ ? {
114
+ line: `${yellow(warnGlyph)} ${name}: missing (run \`nomad pull\` to restore)`,
115
+ fail: false,
116
+ }
117
+ : { line: `${dim(infoGlyph)} ${name}: not synced (nothing in shared/)`, fail: false };
118
+ }
119
+ return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
120
+ }
121
+ if (!stat.isSymbolicLink()) {
122
+ return { line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`, fail: true };
123
+ }
124
+ return classifySymlinkTarget(name, p);
125
+ }
126
+
127
+ /**
128
+ * Resolve the display item for a path already confirmed to be a symlink.
129
+ * Follows the link via statSync; a throw means the target is missing or
130
+ * unreadable. Never FAILs (`fail: false`): a dangling link whose source still
131
+ * lives in the repo is a WARN with a `nomad pull` hint, a dangling link whose
132
+ * source is gone from the repo is an info note (stale, safe to remove), and a
133
+ * non-ENOENT stat error is a WARN naming the code.
134
+ */
135
+ function classifySymlinkTarget(name: string, p: string): { line: string; fail: boolean } {
136
+ try {
137
+ statSync(p);
138
+ return { line: `${green(okGlyph)} ${name}: symlink`, fail: false };
139
+ } catch (err) {
140
+ const code = (err as NodeJS.ErrnoException).code;
141
+ if (code === 'ENOENT') {
142
+ return repoHasSharedSource(name)
143
+ ? {
144
+ line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing, run \`nomad pull\`)`,
145
+ fail: false,
146
+ }
147
+ : {
148
+ line: `${dim(infoGlyph)} ${name}: stale symlink (no longer in shared/, safe to remove)`,
149
+ fail: false,
150
+ };
151
+ }
152
+ return {
153
+ line: `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
154
+ fail: false,
155
+ };
156
+ }
157
+ }
158
+
86
159
  /**
87
160
  * Emits a per-entry status line for each name in SHARED_LINKS
88
- * (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
89
- * process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
161
+ * (okGlyph/warnGlyph/infoGlyph/failGlyph). A non-symlink blocks sync and FAILs
162
+ * via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
90
163
  * that vanishes or becomes unreadable between the probe and the stat yields a
91
- * row instead of an unhandled throw that aborts the whole doctor run. A symlink
92
- * whose target cannot be resolved is a WARN (broken-symlink for a missing
93
- * target, target-unreadable otherwise), never a healthy OK, so a dangling or
94
- * unreadable link is not masked.
164
+ * row instead of an unhandled throw that aborts the whole doctor run. Severity
165
+ * keys off whether the repo still has a `shared/<name>` source: an absent or
166
+ * dangling link is a WARN with a `nomad pull` hint when the source exists (a
167
+ * real out-of-sync state), and a calm info note when it does not (nothing to
168
+ * sync). A symlink whose target cannot be resolved is never a healthy OK, so a
169
+ * dangling or unreadable link is not masked.
95
170
  */
96
171
  export function reportSharedLinks(section: DoctorSection): void {
97
172
  for (const name of SHARED_LINKS) {
98
173
  const p = join(CLAUDE_HOME, name);
99
- let stat;
100
- try {
101
- stat = lstatSync(p);
102
- } catch (err) {
103
- const code = (err as NodeJS.ErrnoException).code;
104
- if (code === 'ENOENT') {
105
- addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
106
- } else {
107
- addItem(section, `${red(failGlyph)} ${name}: could not stat (${String(code)})`);
108
- process.exitCode = 1;
109
- }
110
- continue;
111
- }
112
- if (stat.isSymbolicLink()) {
113
- try {
114
- // statSync follows the link; a throw means the target does not resolve.
115
- statSync(p);
116
- addItem(section, `${green(okGlyph)} ${name}: symlink`);
117
- } catch (err) {
118
- const code = (err as NodeJS.ErrnoException).code;
119
- if (code === 'ENOENT') {
120
- addItem(section, `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`);
121
- } else {
122
- addItem(
123
- section,
124
- `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
125
- );
126
- }
127
- }
128
- } else {
129
- addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
130
- process.exitCode = 1;
131
- }
174
+ const { line, fail } = classifySharedLink(name, p);
175
+ addItem(section, line);
176
+ if (fail) process.exitCode = 1;
132
177
  }
133
178
  }
@@ -70,17 +70,32 @@ function sectionFailed(s: DoctorSection): boolean {
70
70
  * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
71
71
  * `grep -F '✗'` catches both row and header failures), and writes one blank
72
72
  * line between rendered sections (no leading or trailing blank).
73
+ *
74
+ * An empty-string item renders as a true blank line (no tree connector), which
75
+ * lets a reporter set off a footer block (e.g. the `--check-shared` description
76
+ * legend) with vertical whitespace. The `└` connector attaches to the last
77
+ * non-empty item rather than the last array slot so a trailing blank does not
78
+ * strand the elbow on an empty line.
79
+ */
80
+ /**
81
+ * Render one section: a (possibly fail-glyph-prefixed) header followed by its
82
+ * items as a tree. Empty-string items print as true blank lines; the `└` elbow
83
+ * attaches to the last non-empty item so a trailing blank cannot strand it.
73
84
  */
85
+ function renderSection(s: DoctorSection): void {
86
+ const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
87
+ console.log(header);
88
+ const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
89
+ for (let j = 0; j < s.items.length; j++) {
90
+ if (s.items[j] === '') console.log('');
91
+ else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
92
+ }
93
+ }
94
+
74
95
  export function renderDoctor(sections: DoctorSection[]): void {
75
96
  const visible = sections.filter((s) => s.items.length > 0);
76
97
  for (let i = 0; i < visible.length; i++) {
77
98
  if (i > 0) console.log('');
78
- const s = visible[i];
79
- const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
80
- console.log(header);
81
- for (let j = 0; j < s.items.length; j++) {
82
- const isLast = j === s.items.length - 1;
83
- console.log(`${isLast ? ' └ ' : ' ├ '}${s.items[j]}`);
84
- }
99
+ renderSection(visible[i]);
85
100
  }
86
101
  }
@@ -52,9 +52,14 @@ export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = e
52
52
  const ref = parseGitHubRemote(remote);
53
53
  if (ref === null) return;
54
54
 
55
- // Gate 3: gh available and authed. Doctor stays silent on both miss reasons
56
- // (init prints a tip here; doctor does not, per the read-only contract).
57
- if (ghAuthStatus(run) !== null) return;
55
+ // Gate 3: gh available and authed. A definitive gh-not-installed / gh-not-authed
56
+ // result is a silent skip (init prints a tip here; doctor does not, per the
57
+ // read-only contract). A gh-probe-error (the auth-status call timed out or
58
+ // hiccuped) is NOT definitive, so fall through: gates 4-5 run their own probes
59
+ // and silently skip if the network is genuinely down, but the drift WARN can
60
+ // still fire when only the auth-status call blipped on an authed host (#124).
61
+ const auth = ghAuthStatus(run);
62
+ if (auth === 'gh-not-installed' || auth === 'gh-not-authed') return;
58
63
 
59
64
  // Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
60
65
  let isPrivate: boolean;
@@ -62,7 +62,7 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
62
62
  reportRebaseClean(repository);
63
63
  reportMirrorActions(repository);
64
64
 
65
- const version = section('Version');
65
+ const version = section('Version Checks');
66
66
  reportVersionCheck(version);
67
67
  reportNodeEngineCheck(version);
68
68
  reportGitleaksVersionCheck(version);
@@ -11,9 +11,9 @@ import { HOME, UPSTREAM_REPO_SLUG } from './config.ts';
11
11
  * Soft, offline-tolerant release-version check appended to `cmdDoctor`. Reads
12
12
  * the local `package.json.version`, compares it to the latest release tag on
13
13
  * the upstream GitHub repo (cached 1h, 3s curl timeout), and emits one of:
14
- * - `✓ version: <local> (latest)` when local == latest
15
- * - `⚠︎ version: <local> -> <latest>` when local < latest
16
- * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when local > latest
14
+ * - `✓ claude-nomad: <local> (latest)` when local == latest
15
+ * - `⚠︎ claude-nomad: <local> -> <latest>` when local < latest
16
+ * - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when local > latest
17
17
  * Every failure path (offline, curl missing, non-2xx, malformed JSON, missing
18
18
  * `tag_name`, missing/unreadable package.json) is a SILENT skip; this module
19
19
  * never sets `process.exitCode` and never writes to stderr.
@@ -153,9 +153,9 @@ function fetchLatestTag(): string | null {
153
153
  * Emit a single, non-fatal version diagnostic for `nomad doctor` by comparing the local package.json version to the latest upstream release.
154
154
  *
155
155
  * Logs one of:
156
- * - `✓ version: <local> (latest)` when the versions match
157
- * - `⚠︎ version: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
158
- * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when the local version is ahead
156
+ * - `✓ claude-nomad: <local> (latest)` when the versions match
157
+ * - `⚠︎ claude-nomad: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
158
+ * - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when the local version is ahead
159
159
  *
160
160
  * Any failure to read the local version, retrieve or parse the latest release, or use the cache results in no output and does not change `process.exitCode`.
161
161
  */
@@ -181,10 +181,16 @@ export function reportVersionCheck(section: DoctorSection): void {
181
181
 
182
182
  const cmp = compareSemver(localPure, latest);
183
183
  if (cmp === 0) {
184
- addItem(section, `${green(okGlyph)} version: ${local} (latest)`);
184
+ addItem(section, `${green(okGlyph)} claude-nomad: ${local} (latest)`);
185
185
  } else if (cmp === -1) {
186
- addItem(section, `${yellow(warnGlyph)} version: ${local} -> ${latest} (run \`nomad update\`)`);
186
+ addItem(
187
+ section,
188
+ `${yellow(warnGlyph)} claude-nomad: ${local} -> ${latest} (run \`nomad update\`)`,
189
+ );
187
190
  } else {
188
- addItem(section, `${dim(infoGlyph)} version: ${local} (ahead of latest release ${latest})`);
191
+ addItem(
192
+ section,
193
+ `${dim(infoGlyph)} claude-nomad: ${local} (ahead of latest release ${latest})`,
194
+ );
189
195
  }
190
196
  }
package/src/gh-actions.ts CHANGED
@@ -7,10 +7,13 @@ import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
7
7
  export type GhRepoRef = { owner: string; repo: string };
8
8
 
9
9
  /**
10
- * Reason `ghAuthStatus` returned without success. Distinguishes the two
11
- * actionable failure modes so callers can print useful tips.
10
+ * Reason `ghAuthStatus` returned without success. Distinguishes three
11
+ * actionable failure modes so callers decide how to treat each:
12
+ * `gh-not-installed` (the binary is missing), `gh-not-authed` (gh ran and
13
+ * reported no authentication), and `gh-probe-error` (the probe itself failed,
14
+ * e.g. a timeout or transient spawn error, so the auth state is unknown).
12
15
  */
13
- export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed';
16
+ export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed' | 'gh-probe-error';
14
17
 
15
18
  /**
16
19
  * Injectable subprocess runner so tests can mock without `vi.doMock` and
@@ -47,8 +50,16 @@ export function parseGitHubRemote(remoteUrl: string): GhRepoRef | null {
47
50
  /**
48
51
  * Check `gh` CLI availability and auth status in one call. Returns null on
49
52
  * 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.
53
+ * user is authed against github.com and non-zero otherwise.
54
+ *
55
+ * The catch separates a definitive answer from an indeterminate one so callers
56
+ * are not forced to treat a transient probe failure as "not authed":
57
+ * - `ENOENT`: the binary is missing, so `gh-not-installed`.
58
+ * - the child ran and exited with a numeric code (`typeof status === 'number'`,
59
+ * which by spawnSync semantics means it was not signal-killed): the only
60
+ * definitive unauthenticated answer, so `gh-not-authed`.
61
+ * - anything else (a timeout SIGTERM-kills the child so `status` is null, an
62
+ * `ETIMEDOUT`, a spawn hiccup): the probe itself failed, so `gh-probe-error`.
52
63
  */
53
64
  export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReason | null {
54
65
  try {
@@ -58,9 +69,10 @@ export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReas
58
69
  });
59
70
  return null;
60
71
  } catch (err) {
61
- const e = err as { code?: string };
72
+ const e = err as { code?: string; status?: number | null };
62
73
  if (e.code === 'ENOENT') return 'gh-not-installed';
63
- return 'gh-not-authed';
74
+ if (typeof e.status === 'number') return 'gh-not-authed';
75
+ return 'gh-probe-error';
64
76
  }
65
77
  }
66
78
 
package/src/init.ts CHANGED
@@ -159,6 +159,11 @@ function maybeDisableMirrorActions(repoHome: string, run?: SpawnSyncFn): void {
159
159
  );
160
160
  return;
161
161
  }
162
+ // A gh-probe-error (auth-status timed out or hiccuped) is deliberately left to
163
+ // fall through: auth state is unknown, so the privacy probe below tries
164
+ // optimistically with its own catch + tip. This avoids the misleading
165
+ // 'gh auth login' tip a transient failure used to trigger when the user may
166
+ // in fact be authed (#124).
162
167
 
163
168
  let isPrivate: boolean;
164
169
  try {
package/src/nomad.help.ts CHANGED
@@ -5,37 +5,67 @@
5
5
  * cold invocation of `nomad` is self-describing without forcing the user
6
6
  * into the README. Channel is stderr, exit code is 1.
7
7
  */
8
+
9
+ /**
10
+ * Column (0-indexed) at which every command and flag description starts. Sized
11
+ * to clear the longest label (`--resume-cmd <id>`, which ends at column 24)
12
+ * with a two-space gutter. A single constant is what keeps every row aligned;
13
+ * padding lines by hand is how a description drifts out of column.
14
+ */
15
+ const DESC_COL = 26;
16
+
17
+ /**
18
+ * Render a `label` + `desc` help row, padding the label out to DESC_COL so the
19
+ * description lands in the shared column. `padEnd` is a no-op when a label is
20
+ * already at or past the column, so no row can throw or fall out of alignment.
21
+ */
22
+ const row = (label: string, desc: string): string => label.padEnd(DESC_COL) + desc;
23
+
24
+ /**
25
+ * Indent a continuation line (wrapped description text with no label of its
26
+ * own) to DESC_COL so it sits directly under the description column.
27
+ */
28
+ const cont = (text: string): string => ' '.repeat(DESC_COL) + text;
29
+
8
30
  export const DEFAULT_HELP = [
9
31
  'usage: nomad <command> [flags]',
10
32
  '',
11
33
  'Commands:',
12
- ' pull Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).',
13
- ' --dry-run Run lock + git pull, then preview every mutation without writing.',
34
+ row(' pull', 'Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).'),
35
+ row(' --dry-run', 'Run lock + git pull, then preview every mutation without writing.'),
36
+ '',
37
+ row(' push', 'Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.'),
38
+ row(' --dry-run', 'Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview'),
39
+ cont('remap, without staging or pushing.'),
14
40
  '',
15
- ' push Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.',
16
- ' --dry-run Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview',
17
- ' remap, without staging or pushing.',
41
+ row(' diff', 'Offline preview of what `pull` would change against local repo state.'),
42
+ cont('No git pull, no lock acquired.'),
18
43
  '',
19
- ' diff Offline preview of what `pull` would change against local repo state.',
20
- ' No git pull, no lock acquired.',
44
+ row(' init', 'Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).'),
45
+ row(' --snapshot', 'Overlay the current ~/.claude/ into shared/ as the initial seed.'),
46
+ row(' --keep-actions', 'Skip auto-disabling GitHub Actions on the private mirror.'),
21
47
  '',
22
- ' init Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).',
23
- ' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
24
- ' --keep-actions Skip auto-disabling GitHub Actions on the private mirror.',
48
+ row(' doctor', 'Read-only health check (symlinks, host file, path-map,'),
49
+ cont('gitleaks, gitlinks).'),
50
+ row(' --check-shared', 'Preflight gitleaks scan of the session transcripts a'),
51
+ cont('`nomad push` would stage (a temp copy, never the live dir).'),
52
+ row(' --resume-cmd <id>', 'Print `cd <abspath> && claude --resume <id>` for a session id'),
53
+ cont('from ~/.claude/projects/.'),
25
54
  '',
26
- ' doctor Read-only health check (symlinks, host file, path-map,',
27
- ' gitleaks, gitlinks).',
28
- ' --check-shared Preflight gitleaks scan of the session transcripts a',
29
- ' `nomad push` would stage (a temp copy, never the live dir).',
30
- ' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
31
- ' from ~/.claude/projects/.',
55
+ row(
56
+ ' drop-session <id>',
57
+ 'Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
58
+ ),
32
59
  '',
33
- ' drop-session <id> Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
60
+ row(' update', 'Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.'),
61
+ row(' --dry-run', 'Detect topology + pre-flight, print would-be git commands only.'),
62
+ row(' --force', 'Proceed even when the working tree is not clean.'),
63
+ row(
64
+ ' --push-origin',
65
+ 'Fork topology only: push the merge to origin/main without prompting.',
66
+ ),
34
67
  '',
35
- ' update Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.',
36
- ' --dry-run Detect topology + pre-flight, print would-be git commands only.',
37
- ' --force Proceed even when the working tree is not clean.',
38
- ' --push-origin Fork topology only: push the merge to origin/main without prompting.',
68
+ row(' --version', 'Print the installed CLI version as bare semver to stdout; exits 0.'),
39
69
  '',
40
70
  'Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json',
41
71
  'in the repo, never ~/.claude/settings.json directly (it is regenerated on',
package/src/nomad.ts CHANGED
@@ -154,15 +154,11 @@ try {
154
154
  // Single positional argv; cmdDropSession revalidates id at entry as
155
155
  // defense-in-depth (the function may be called from non-argv paths
156
156
  // in tests). The argv regex mirrors the function-entry allowlist
157
- // (`[A-Za-z0-9_-]`) but additionally rejects ids starting with `-`
157
+ // (`[\w-]`) but additionally rejects ids starting with `-`
158
158
  // so a typo like `nomad drop-session --bogus` shows the usage line,
159
159
  // not a FATAL. The length bound matches cmdDropSession.
160
160
  const id = process.argv[3];
161
- if (
162
- process.argv.length !== 4 ||
163
- typeof id !== 'string' ||
164
- !/^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/.test(id)
165
- ) {
161
+ if (process.argv.length !== 4 || typeof id !== 'string' || !/^\w[\w-]{0,127}$/.test(id)) {
166
162
  console.error('usage: nomad drop-session <id>');
167
163
  process.exit(1);
168
164
  }