@ahood/cli 0.9.0 → 0.10.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/dist/index.js CHANGED
@@ -20,15 +20,16 @@ import { versions } from "./commands/versions.js";
20
20
  import { diff } from "./commands/diff.js";
21
21
  import { completion } from "./commands/completion.js";
22
22
  import { mcp } from "./commands/mcp.js";
23
+ import { extractKindFlag, resolveScope } from "./kinds.js";
24
+ import { readUseme } from "./useme.js";
25
+ import { UsageError } from "./usage-error.js";
23
26
  import { init } from "./commands/init.js";
24
27
  import { createGroup, listGroups, groupMembers, inviteLink, joinGroup, removeMember, leaveGroup, deleteGroup, } from "./commands/group.js";
25
28
  import { createSnap, listSnaps, searchSnaps, showSnap, removeSnap, shareSnap, unshareSnap, tagsSnap, } from "./commands/snap.js";
26
- import { formatHelp, formatSkillHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
29
+ import { formatHelp, formatKindHelp, formatGroupHelp, formatSnapHelp, formatCommandHelp, findCommandHelp } from "./help.js";
27
30
  import { ApiError } from "./http.js";
28
31
  import { exitCodeFor } from "./exit-code.js";
29
32
  import { CLI_NAME, CLI_VERSION } from "./version.js";
30
- // Every skill-entity verb, reached only as `ahood skill <verb>` -- see
31
- // dispatchSkill() below. "show" is an alias for "view" (issue #30).
32
33
  const SKILL_COMMANDS = {
33
34
  search,
34
35
  view,
@@ -44,7 +45,7 @@ const SKILL_COMMANDS = {
44
45
  // can never accidentally move a pin forward. Reuses update()'s existing
45
46
  // "no targets = every installed skill, explicit targets = just those"
46
47
  // behavior as-is; no new logic.
47
- outdated: (args) => update([...args, "--dry-run"]),
48
+ outdated: (args, scope) => (scope ? update([...args, "--dry-run"], scope) : update([...args, "--dry-run"])),
48
49
  remove,
49
50
  edit,
50
51
  unpublish,
@@ -92,8 +93,9 @@ const COMMANDS = {
92
93
  whoami,
93
94
  token,
94
95
  completion,
95
- mcp,
96
+ mcp: dispatchMcp,
96
97
  skill: dispatchSkill,
98
+ agent: dispatchAgent,
97
99
  group: dispatchGroup,
98
100
  snap: dispatchSnap,
99
101
  };
@@ -105,7 +107,8 @@ const COMMANDS = {
105
107
  // here just means "a command that owns its own sub-verb dispatch", the same
106
108
  // sense "skill" already used before "group" (the entity) existed. "snap"
107
109
  // joins this set for the exact same reason (ahood-cli#107).
108
- const GROUP_COMMANDS = new Set(["skill", "group", "snap"]);
110
+ // "agent" and "mcp" join it as registry nouns (ahood-cli#172).
111
+ const GROUP_COMMANDS = new Set(["skill", "agent", "mcp", "group", "snap"]);
109
112
  function levenshtein(a, b) {
110
113
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
111
114
  for (let i = 0; i <= a.length; i++)
@@ -143,31 +146,96 @@ function closestGroupVerb(input) {
143
146
  function closestSnapVerb(input) {
144
147
  return closestOf(input, Object.keys(SNAP_VERBS));
145
148
  }
146
- // `ahood skill <verb> [...]` -- owns its own help handling at both the
147
- // group level (`ahood skill` / `ahood skill --help`) and the per-verb level
148
- // (`ahood skill <verb> --help`), since neither should fall through to
149
- // main()'s generic top-level --help interception (see GROUP_COMMANDS).
150
- async function dispatchSkill(args) {
149
+ // `ahood <noun> <verb> [...]` for every registry noun (skill, agent, mcp) --
150
+ // owns its own help handling at both the noun level (`ahood agent` /
151
+ // `ahood agent --help`) and the per-verb level (`ahood agent <verb> --help`),
152
+ // since neither should fall through to main()'s generic top-level --help
153
+ // interception (see GROUP_COMMANDS).
154
+ //
155
+ // Scope (ahood-cli#172): `--kind <k|all>` is consumed here, never by a
156
+ // handler, and resolved by resolveScope(): agent/mcp are strict for their own
157
+ // kind (a contradictory --kind is a usage error), while skill stays legacy
158
+ // cross-kind unless --kind narrows it. `ahood skill publish` is the one
159
+ // exception: its --kind has always been publish's own "what kind of artifact
160
+ // is this folder" flag, so it is passed through untouched.
161
+ async function dispatchRegistry(noun, args) {
151
162
  const [sub, ...rest] = args;
152
163
  if (!sub || sub === "--help" || sub === "-h") {
153
- console.log(formatSkillHelp());
164
+ console.log(formatKindHelp(noun));
154
165
  return;
155
166
  }
156
167
  const handler = SKILL_COMMANDS[sub];
157
168
  if (!handler) {
158
- console.error(`Unknown skill command: ${sub}`);
159
- const suggestion = closestSkillCommand(sub);
169
+ console.error(`Unknown ${noun} command: ${sub}`);
170
+ const suggestion = closestOf(sub, noun === "mcp" ? ["serve", ...Object.keys(SKILL_COMMANDS)] : Object.keys(SKILL_COMMANDS));
160
171
  if (suggestion)
161
172
  console.error(`Did you mean '${suggestion}'?`);
162
- console.error("Run `ahood skill --help` for a list of commands.");
173
+ console.error(`Run \`ahood ${noun} --help\` for a list of commands.`);
174
+ if (noun === "mcp") {
175
+ console.error("To start the local MCP server, run `ahood mcp serve` (or `ahood mcp` with no arguments).");
176
+ }
163
177
  process.exit(2);
164
178
  }
165
179
  if (rest.includes("--help") || rest.includes("-h")) {
166
- const entry = findCommandHelp("skill", sub);
167
- console.log(entry ? formatCommandHelp(entry) : formatSkillHelp());
180
+ const entry = findCommandHelp(noun, sub);
181
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp(noun));
168
182
  return;
169
183
  }
170
- await handler(rest);
184
+ if (noun === "skill" && sub === "publish") {
185
+ await handler(rest);
186
+ return;
187
+ }
188
+ const { kind, rest: handlerArgs } = extractKindFlag(rest);
189
+ const scope = resolveScope(noun, kind);
190
+ if (scope.kind === "all") {
191
+ // Legacy: the exact pre-#172 call, minus an explicit `--kind all`.
192
+ await handler(kind === undefined ? rest : handlerArgs);
193
+ return;
194
+ }
195
+ try {
196
+ await handler(handlerArgs, scope);
197
+ }
198
+ catch (error) {
199
+ // Handlers phrase their usage lines as `ahood skill <verb>`; under another
200
+ // noun, say the command the user actually typed.
201
+ if (error instanceof UsageError && noun !== "skill") {
202
+ error.message = error.message.replace(/(Usage: |or: )ahood skill /g, `$1ahood ${noun} `);
203
+ }
204
+ throw error;
205
+ }
206
+ }
207
+ async function dispatchSkill(args) {
208
+ await dispatchRegistry("skill", args);
209
+ }
210
+ async function dispatchAgent(args) {
211
+ await dispatchRegistry("agent", args);
212
+ }
213
+ // `ahood mcp` keeps its original contract byte-for-byte: with NO arguments it
214
+ // is the local stdio MCP server that existing host configs launch -- nothing
215
+ // is printed and nothing else runs first. `ahood mcp serve` is the same
216
+ // server under an explicit, documented name. Every other first word is a
217
+ // registry verb for mcp-kind entries, help, or an error: an unrecognized one
218
+ // fails with usage (dispatchRegistry) rather than silently starting a
219
+ // long-lived server that a typo would otherwise leave hanging.
220
+ async function dispatchMcp(args) {
221
+ if (args.length === 0) {
222
+ await mcp(args);
223
+ return;
224
+ }
225
+ const [sub, ...rest] = args;
226
+ if (sub === "serve") {
227
+ if (rest.includes("--help") || rest.includes("-h")) {
228
+ const entry = findCommandHelp("mcp", "serve");
229
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp("mcp"));
230
+ return;
231
+ }
232
+ if (rest.length > 0) {
233
+ throw new UsageError(`\`ahood mcp serve\` takes no arguments (got: ${rest.join(" ")}).\nUsage: ahood mcp serve`);
234
+ }
235
+ await mcp([]);
236
+ return;
237
+ }
238
+ await dispatchRegistry("mcp", args);
171
239
  }
172
240
  // `ahood group <verb> [...]` -- owns its own help handling at both the
173
241
  // group level (`ahood group` / `ahood group --help`) and the per-verb level
@@ -228,10 +296,26 @@ async function main() {
228
296
  }
229
297
  if (command === "help") {
230
298
  const sub = args[0];
231
- if (sub === "skill") {
299
+ // `ahood help useme` (ahood-cli#172): the bundled self-skill, as raw
300
+ // bytes on stdout and nothing else -- no banner, no trailing newline of
301
+ // our own -- so the output IS the SKILL.md. No network, no credentials,
302
+ // no project writes.
303
+ if (sub === "useme") {
304
+ let content;
305
+ try {
306
+ content = readUseme();
307
+ }
308
+ catch (error) {
309
+ console.error(`Could not read the bundled ahood SKILL.md: ${error instanceof Error ? error.message : String(error)}`);
310
+ process.exit(1);
311
+ }
312
+ process.stdout.write(content);
313
+ return;
314
+ }
315
+ if (sub === "skill" || sub === "agent" || sub === "mcp") {
232
316
  const verb = args[1];
233
- const entry = verb ? findCommandHelp("skill", verb) : undefined;
234
- console.log(entry ? formatCommandHelp(entry) : formatSkillHelp());
317
+ const entry = verb ? findCommandHelp(sub, verb) : undefined;
318
+ console.log(entry ? formatCommandHelp(entry) : formatKindHelp(sub));
235
319
  return;
236
320
  }
237
321
  if (sub === "group") {
@@ -310,4 +394,4 @@ if (isEntrypoint) {
310
394
  main();
311
395
  }
312
396
  // Exported for tests only -- the CLI itself only ever calls main() above.
313
- export { main, dispatchSkill, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
397
+ export { main, dispatchSkill, dispatchAgent, dispatchMcp, dispatchRegistry, dispatchGroup, dispatchSnap, COMMANDS, SKILL_COMMANDS, GROUP_VERBS, SNAP_VERBS };
@@ -0,0 +1,40 @@
1
+ // Pure kind metadata (no imports, no I/O) so help.ts -- which
2
+ // scripts/sync-readme.mjs imports straight from dist/ -- can use it without
3
+ // pulling in the HTTP/filesystem layer. Behavior lives in kinds.ts.
4
+ //
5
+ // Kind-aware registry commands (ahood-cli#172).
6
+ //
7
+ // The registry stores four artifact kinds (the backend's
8
+ // lib/skills/validation.ts ALLOWED_KINDS: skill, agent, mcp, doc), and a
9
+ // kind is fixed when an entry is created -- PATCH's allow-list has no `kind`
10
+ // -- so the kind an entry reports now is the kind it has always had. This
11
+ // CLI has a noun group for three of them. `doc` is a real registry kind left
12
+ // out of this CLI's scope on purpose; adding it means one entry in KINDS
13
+ // below plus its help/init template, not a new dispatcher.
14
+ export const KINDS = {
15
+ skill: {
16
+ label: "skill",
17
+ article: "a",
18
+ plural: "skills",
19
+ rootDoc: "SKILL.md",
20
+ installsTo: ".claude/skills/<owner>@<skill>/",
21
+ },
22
+ agent: {
23
+ label: "agent",
24
+ article: "an",
25
+ plural: "agents",
26
+ rootDoc: "AGENT.md",
27
+ installsTo: ".claude/agents/<owner>@<agent>.md",
28
+ },
29
+ mcp: {
30
+ label: "MCP server manifest",
31
+ article: "an",
32
+ plural: "MCP server manifests",
33
+ rootDoc: "server.json",
34
+ installsTo: "an entry in .mcp.json",
35
+ },
36
+ };
37
+ export const CLI_KINDS = Object.keys(KINDS);
38
+ export function isCliKind(value) {
39
+ return typeof value === "string" && CLI_KINDS.includes(value);
40
+ }
package/dist/kinds.js ADDED
@@ -0,0 +1,122 @@
1
+ // Kind-aware registry commands (ahood-cli#172): scope resolution and the
2
+ // wrong-kind guards every kind-scoped verb runs before its first side effect.
3
+ // The kind table itself lives in kind-info.ts.
4
+ import { existsSync } from "node:fs";
5
+ import { apiJson } from "./http.js";
6
+ import { agentPath, skillDir } from "./spec.js";
7
+ import { UsageError } from "./usage-error.js";
8
+ import { KINDS, CLI_KINDS, isCliKind } from "./kind-info.js";
9
+ export { KINDS, CLI_KINDS, isCliKind };
10
+ export const LEGACY_SCOPE = { noun: "skill", kind: "all" };
11
+ export function strictKind(scope) {
12
+ return scope && scope.kind !== "all" ? scope.kind : undefined;
13
+ }
14
+ // A wrong-kind target: the user pointed a noun at the wrong kind of entry, so
15
+ // it maps to exit 2 ("fix your input") through UsageError, the same code a
16
+ // server-side validation refusal gets.
17
+ export class KindMismatchError extends UsageError {
18
+ }
19
+ export function describeKind(kind) {
20
+ return `${KINDS[kind].article} ${KINDS[kind].label}`;
21
+ }
22
+ // Throws unless `actual` is the scope's kind. A no-op for the legacy scope.
23
+ //
24
+ // Missing or unrecognized kind metadata is refused, never guessed: the legacy
25
+ // code treated a missing `kind` as a skill, and a strict verb that did the
26
+ // same would silently misclassify exactly the entry it exists to protect.
27
+ // That refusal is a plain Error (exit 1) rather than a KindMismatchError,
28
+ // since nothing about the user's input is wrong -- the registry's answer is.
29
+ export function assertKind(scope, key, actual, verb, action) {
30
+ const expected = strictKind(scope);
31
+ if (expected === undefined || actual === expected)
32
+ return;
33
+ const legacyHint = `\`ahood skill ${verb} ${key}\` (legacy, any kind)`;
34
+ if (isCliKind(actual)) {
35
+ throw new KindMismatchError(`${key} is ${describeKind(actual)}, not ${describeKind(expected)} -- refusing to ${action} it. ` +
36
+ `Use \`ahood ${actual} ${verb} ${key}\` instead.`);
37
+ }
38
+ if (actual === undefined || actual === null || actual === "") {
39
+ throw new Error(`The registry did not report a kind for ${key}, so ahood cannot confirm it is ${describeKind(expected)} -- ` +
40
+ `refusing to ${action} it. If you are sure, use ${legacyHint}.`);
41
+ }
42
+ throw new Error(`${key} has kind "${String(actual).slice(0, 40)}", which \`ahood ${scope.noun}\` does not handle -- ` +
43
+ `refusing to ${action} it. Use ${legacyHint} if you really mean it.`);
44
+ }
45
+ // Resolves an existing registry entry's kind and asserts it, for verbs whose
46
+ // own request carries no kind (star, share, edit, versions, ...). A no-op --
47
+ // and, importantly, no extra request -- for the legacy scope, so
48
+ // `ahood skill <verb>` sends exactly what it always has.
49
+ export async function ensureRemoteKind(scope, owner, skill, verb, action) {
50
+ if (strictKind(scope) === undefined)
51
+ return;
52
+ const detail = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
53
+ assertKind(scope, `${owner}/${skill}`, detail.kind, verb, action);
54
+ }
55
+ // What this project's own files say an installed pin is, without asking the
56
+ // registry. The lockfile records no `kind` (and this change does not migrate
57
+ // it), so the footprint each kind leaves is the evidence:
58
+ // skill -> a .claude/skills/<owner>@<skill>/ directory
59
+ // agent -> a .claude/agents/<owner>@<skill>.md file
60
+ // mcp -> neither, plus the mcp_config_hash only an mcp install records
61
+ // Anything else (both, or a bare pin with no hash -- a legacy mcp pin or a
62
+ // skill whose directory was deleted by hand) is undefined: unknown, and the
63
+ // caller must ask the registry rather than guess.
64
+ export function localInstalledKind(owner, skill, entry) {
65
+ const hasDir = existsSync(skillDir(owner, skill));
66
+ const hasAgentFile = existsSync(agentPath(owner, skill));
67
+ if (hasDir && !hasAgentFile)
68
+ return "skill";
69
+ if (hasAgentFile && !hasDir)
70
+ return "agent";
71
+ if (!hasDir && !hasAgentFile && entry?.mcp_config_hash !== undefined)
72
+ return "mcp";
73
+ return undefined;
74
+ }
75
+ // Pulls `--kind <value>` / `--kind=<value>` out of a registry verb's args.
76
+ // Returns the remaining args untouched otherwise, so a handler never sees a
77
+ // flag it does not declare.
78
+ export function extractKindFlag(args) {
79
+ let kind;
80
+ const rest = [];
81
+ for (let i = 0; i < args.length; i++) {
82
+ const arg = args[i];
83
+ if (arg === "--kind" || arg.startsWith("--kind=")) {
84
+ if (kind !== undefined)
85
+ throw new UsageError("--kind given more than once. Pass it once.");
86
+ if (arg === "--kind") {
87
+ const value = args[i + 1];
88
+ if (value === undefined || value.startsWith("--"))
89
+ throw new UsageError("--kind requires a value.");
90
+ kind = value;
91
+ i++;
92
+ }
93
+ else {
94
+ kind = arg.slice("--kind=".length);
95
+ }
96
+ continue;
97
+ }
98
+ rest.push(arg);
99
+ }
100
+ return { kind, rest };
101
+ }
102
+ // Resolves the scope for `ahood <noun> <verb> [--kind <k>]`.
103
+ // skill noun, no --kind or --kind all -> legacy (cross-kind)
104
+ // skill noun, --kind <k> -> strict <k>
105
+ // agent/mcp noun, no --kind or --kind <same> -> strict <noun>
106
+ // agent/mcp noun, any other --kind -> usage error (contradiction)
107
+ export function resolveScope(noun, kindFlag) {
108
+ const valid = [...CLI_KINDS, "all"].join(", ");
109
+ if (kindFlag !== undefined && kindFlag !== "all" && !isCliKind(kindFlag)) {
110
+ throw new UsageError(`--kind must be one of: ${valid} (got "${kindFlag}").`);
111
+ }
112
+ if (noun === "skill") {
113
+ return kindFlag === undefined || kindFlag === "all" ? LEGACY_SCOPE : { noun, kind: kindFlag };
114
+ }
115
+ if (kindFlag !== undefined && kindFlag !== noun) {
116
+ throw new UsageError(`\`ahood ${noun}\` only operates on ${KINDS[noun].plural} -- --kind ${kindFlag} contradicts it. ` +
117
+ (kindFlag === "all"
118
+ ? "Use `ahood skill <verb>` (legacy, any kind) for a cross-kind operation."
119
+ : `Use \`ahood ${kindFlag} <verb>\` instead.`));
120
+ }
121
+ return { noun, kind: noun };
122
+ }
package/dist/lockfile.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { hostname } from "node:os";
3
4
  export function readLockfile(path) {
4
5
  if (!existsSync(path))
5
6
  return {};
@@ -130,7 +131,21 @@ function resolveWriteTarget(path) {
130
131
  // .mcp.json merge, which sits right next to the lockfile and can hold other
131
132
  // servers' secrets) get the same crash-safety guarantee instead of a bare
132
133
  // writeFileSync that risks truncating the file on interruption.
133
- export function writeJsonFileAtomic(path, data) {
134
+ //
135
+ // `mode` (ahood-cli#158) lets a caller decide the mode of a file this write
136
+ // CREATES: installMcpEntry requests 0600 when the payload it just resolved
137
+ // holds a credential, because only the caller knows whether the data is a
138
+ // secret store or a shared config. It never applies to an existing
139
+ // destination -- that file's mode is its owner's (or another tool's) choice,
140
+ // and preserving it is #119's whole point -- so an existing file is untouched
141
+ // no matter what is requested, and the caller learns about a loose existing
142
+ // file some other way (the warning in add.ts). Omitted, the fallback stays
143
+ // exactly what a plain writeFileSync would have produced. An explicitly
144
+ // requested mode is applied exactly, via the post-rename chmod below, rather
145
+ // than umask-masked at creation: a caller making a security decision about a
146
+ // file it is creating must get what it asked for regardless of the ambient
147
+ // umask.
148
+ export function writeJsonFileAtomic(path, data, mode) {
134
149
  // Resolved before the mkdir so the directory that actually gets created is
135
150
  // the resolved target's, not the link's -- a link set up ahead of the first
136
151
  // install can point somewhere that doesn't exist yet -- and so that every
@@ -153,9 +168,11 @@ export function writeJsonFileAtomic(path, data) {
153
168
  // permissions matters because .mcp.json is a project-shared config other MCP
154
169
  // clients read: whether it should be 0644 or tighter is its own decision
155
170
  // (ahood#169), not something this write path gets to change as a side effect
156
- // of the 0600 temp file below (ahood-cli#119). With no destination yet there
157
- // is nothing to preserve, so fall back to what a plain writeFileSync would
158
- // have produced -- 0666 masked by the process umask.
171
+ // of the 0600 temp file below (ahood-cli#119) -- and that is true even when
172
+ // the caller passed `mode`, which therefore only ever governs creation. With
173
+ // no destination yet there is nothing to preserve: an explicit request wins
174
+ // (see the comment above), and without one the fallback is what a plain
175
+ // writeFileSync would have produced -- 0666 masked by the process umask.
159
176
  //
160
177
  // lstatSync rather than statSync even though `dest` is by construction never
161
178
  // a symlink (resolveWriteTarget walked until it wasn't): the two agree here,
@@ -166,7 +183,7 @@ export function writeJsonFileAtomic(path, data) {
166
183
  finalMode = lstatSync(dest).mode & 0o777;
167
184
  }
168
185
  catch {
169
- finalMode = 0o666 & ~process.umask();
186
+ finalMode = mode !== undefined ? mode & 0o777 : 0o666 & ~process.umask();
170
187
  }
171
188
  try {
172
189
  // 0600 from the moment of creation: for .mcp.json this temp file holds
@@ -233,20 +250,98 @@ function readLockPid(pidFile) {
233
250
  const pid = Number(pidText);
234
251
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
235
252
  }
253
+ // /etc/machine-id is the stable per-machine identifier on systemd Linux:
254
+ // stable across reboots, distinct per machine. Hostname alone is not a
255
+ // sufficient identity -- two containers from the same image routinely share
256
+ // it -- so the machine id anchors the comparison wherever it exists. Read
257
+ // errors are swallowed (macOS has no such file; a read can also fail) and
258
+ // leave the identity to the hostname alone, the same best-effort posture the
259
+ // pid write below already takes.
260
+ function readMachineId() {
261
+ try {
262
+ const machineId = readFileSync("/etc/machine-id", "utf-8").trim();
263
+ return machineId.length > 0 ? machineId : undefined;
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ // The exact string written into a lock's `host` file: first line the machine
270
+ // id (empty when this host has none), second line the hostname. Computed once
271
+ // and cached -- neither value can change under a running process, and the
272
+ // retry loop below re-checks staleness every 25ms. Exported so a test can
273
+ // plant a lock recorded by THIS host (a foreign one just writes any other
274
+ // string), and so the format a reader must parse has exactly one definition.
275
+ let cachedHostIdentity;
276
+ export function currentHostIdentity() {
277
+ cachedHostIdentity ??= `${readMachineId() ?? ""}\n${hostname()}`;
278
+ return cachedHostIdentity;
279
+ }
280
+ function parseHostIdentity(text) {
281
+ const newline = text.indexOf("\n");
282
+ // No newline: tolerate hostname-only content rather than reading the whole
283
+ // line as a machine id that could never match.
284
+ if (newline === -1)
285
+ return { hostname: text };
286
+ return { machineId: text.slice(0, newline) || undefined, hostname: text.slice(newline + 1) };
287
+ }
288
+ // A recorded identity matches this host when the machine ids agree, or -- when
289
+ // either side has no machine id -- the hostnames do. Machine id deliberately
290
+ // wins over hostname where both exist: the same machine-id under a different
291
+ // hostname is the same machine (renamed), while a shared hostname over
292
+ // different machine ids is exactly the two-containers-from-one-image case
293
+ // hostname-only matching cannot tell apart -- the bug this exists to close.
294
+ function isSameHost(recorded) {
295
+ const ours = parseHostIdentity(currentHostIdentity());
296
+ const theirs = parseHostIdentity(recorded);
297
+ if (ours.machineId && theirs.machineId)
298
+ return ours.machineId === theirs.machineId;
299
+ return ours.hostname === theirs.hostname;
300
+ }
301
+ // The `host` file a lock records its holder's identity in, or undefined when
302
+ // there is no readable one: no host file at all, or contents that trim to
303
+ // nothing. See isLockStale for what a missing host line means.
304
+ function readLockHost(hostFile) {
305
+ try {
306
+ const host = readFileSync(hostFile, "utf-8").trim();
307
+ return host.length > 0 ? host : undefined;
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
236
313
  // A lock directory is stale if the pid recorded inside it belongs to a process
237
- // that's no longer running, per isPidDead above. No readable pid means "not
238
- // stale": safer to wait out a lock that may still be being set up than to
239
- // reclaim one that is actually held.
240
- function isLockStale(pidFile) {
314
+ // that's no longer running, per isPidDead above -- but only a lock recorded on
315
+ // THIS host: the pid probe cannot see another host's processes, so a lock
316
+ // whose host line names a different machine is treated as live and waited out
317
+ // no matter what the pid probe answers (ahood-cli#157). A missing host line is
318
+ // judged by pid alone, exactly as <=0.9.0 code judged every lock: the only
319
+ // such locks are legacy ones and those whose best-effort host write failed,
320
+ // and reading them as foreign instead would strand every stale local lock
321
+ // behind a misleading "held on another host" timeout -- a regression #100's
322
+ // reclaim exists to prevent, in exchange for keeping only the pre-#157 status
323
+ // quo, which disappears as soon as a lock carries a host line.
324
+ //
325
+ // No readable pid still means "not stale": safer to wait out a lock that may
326
+ // still be being set up than to reclaim one that is actually held.
327
+ function isLockStale(pidFile, hostFile) {
241
328
  const pid = readLockPid(pidFile);
242
- return pid !== undefined && isPidDead(pid);
329
+ if (pid === undefined)
330
+ return false;
331
+ const host = readLockHost(hostFile);
332
+ return (host === undefined || isSameHost(host)) && isPidDead(pid);
243
333
  }
244
334
  // Deliberately NOT the negation of isLockStale: with no readable pid at all a
245
335
  // lock is neither stale nor demonstrably held, and the two callers want
246
336
  // opposite answers in that case. Reclaiming needs proof the holder is dead
247
337
  // (hence isLockStale), while withLock's deadline branch needs proof that
248
338
  // someone is alive before it backs off a reclaim failure it already observed.
249
- function isLockHeldByLiveProcess(pidFile) {
339
+ // A foreign-host lock counts as held no matter what the pid probe answers:
340
+ // that holder is exactly the process this machine cannot see (ahood-cli#157).
341
+ function isLockHeldByLiveProcess(pidFile, hostFile) {
342
+ const host = readLockHost(hostFile);
343
+ if (host !== undefined && !isSameHost(host))
344
+ return true;
250
345
  const pid = readLockPid(pidFile);
251
346
  return pid !== undefined && !isPidDead(pid);
252
347
  }
@@ -287,7 +382,7 @@ const RECLAIM_CLAIM = "reclaiming";
287
382
  //
288
383
  // Returns the error to remember for withLock's deadline branch (see
289
384
  // ahood-cli#140), or undefined when there is nothing worth remembering.
290
- function reclaimStaleLock(lockDir, pidFile) {
385
+ function reclaimStaleLock(lockDir, pidFile, hostFile) {
291
386
  const claim = join(lockDir, RECLAIM_CLAIM);
292
387
  try {
293
388
  mkdirSync(claim);
@@ -303,7 +398,7 @@ function reclaimStaleLock(lockDir, pidFile) {
303
398
  return undefined;
304
399
  return existsSync(lockDir) ? error : undefined;
305
400
  }
306
- if (!isLockStale(pidFile)) {
401
+ if (!isLockStale(pidFile, hostFile)) {
307
402
  // Not the directory that was judged stale after all -- it was reclaimed and
308
403
  // retaken while this process looked, or it belongs to a holder that hasn't
309
404
  // written its pid yet. Hand the claim back, leaving the lock exactly as it
@@ -340,11 +435,16 @@ function reclaimStaleLock(lockDir, pidFile) {
340
435
  // hard-killed (SIGKILL/OOM) mid-critical-section: without this, such a lock
341
436
  // is never released, and every subsequent add/remove/update in that project
342
437
  // busy-waits out the full timeout below and then fails permanently until a
343
- // human manually deletes the stale directory (ahood-cli#100).
438
+ // human manually deletes the stale directory (ahood-cli#100). It also holds
439
+ // a `host` file recording WHERE that holder ran, because a pid is only
440
+ // meaningful on the machine it belongs to: on a shared filesystem a live
441
+ // holder on another host reads as dead here, and reclaiming its lock puts
442
+ // two processes inside the critical section at once (ahood-cli#157).
344
443
  export function withLock(path, fn) {
345
444
  mkdirSync(dirname(path), { recursive: true });
346
445
  const lockDir = `${path}.lock`;
347
446
  const pidFile = join(lockDir, "pid");
447
+ const hostFile = join(lockDir, "host");
348
448
  const deadline = Date.now() + 5000;
349
449
  // Set when the lock was judged stale but could not actually be removed and
350
450
  // that directory is still sitting there -- see the reclaim branch below.
@@ -364,6 +464,13 @@ export function withLock(path, fn) {
364
464
  try {
365
465
  writeFileSync(pidFile, String(process.pid));
366
466
  wrotePid = true;
467
+ // Best-effort like the pid write above, and for the same reason:
468
+ // mkdirSync is what actually holds the lock. A lock whose host line
469
+ // never landed carries no host identity and is judged exactly as
470
+ // pre-#157 code judged every lock -- by pid alone (see isLockStale's
471
+ // missing-host rule) -- so a failed write here degrades to today's
472
+ // behaviour rather than to something new.
473
+ writeFileSync(hostFile, currentHostIdentity());
367
474
  }
368
475
  catch {
369
476
  // non-fatal, see comment above
@@ -391,12 +498,30 @@ export function withLock(path, fn) {
391
498
  // (inode, birthtime) deliberately isn't used for this: a removed and
392
499
  // immediately recreated directory routinely gets the same inode back,
393
500
  // so it can't tell a replacement from the original anyway.
394
- if (reclaimError && existsSync(lockDir) && !isLockHeldByLiveProcess(pidFile)) {
501
+ if (reclaimError && existsSync(lockDir) && !isLockHeldByLiveProcess(pidFile, hostFile)) {
395
502
  throw new Error(`Could not reclaim the stale lock on ${path} at ${lockDir}: ${reclaimError.code ?? reclaimError.message}. The process that held it is gone, but this one cannot remove that directory -- delete it manually (it may belong to another user, e.g. left behind by a sudo/root run, or sit on a read-only filesystem).`);
396
503
  }
504
+ // A lock recorded on another host never reaches the reclaim branch
505
+ // (isLockStale refuses to judge it stale), so its timeout lands here.
506
+ // Name the holder's host rather than falling through to the generic
507
+ // message: the pid in that lock is dead by this host's lights, so "if
508
+ // no other ahood process is running" reads as permission to delete a
509
+ // lock that may well belong to a live process this machine cannot see
510
+ // (ahood-cli#157).
511
+ const recordedHost = readLockHost(hostFile);
512
+ if (recordedHost !== undefined && !isSameHost(recordedHost)) {
513
+ const holder = parseHostIdentity(recordedHost);
514
+ const holderPid = readLockPid(pidFile);
515
+ const holderParts = [
516
+ `hostname "${holder.hostname}"`,
517
+ ...(holder.machineId ? [`machine id ${holder.machineId}`] : []),
518
+ ...(holderPid !== undefined ? [`pid ${holderPid}`] : []),
519
+ ].join(", ");
520
+ throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}: it is held on another host (${holderParts}), which this machine cannot probe for liveness. If nothing on that host holds it any more, delete that directory.`);
521
+ }
397
522
  throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
398
523
  }
399
- if (isLockStale(pidFile)) {
524
+ if (isLockStale(pidFile, hostFile)) {
400
525
  // Reclaim it. Two very different conditions can fail that, and only
401
526
  // one of them is the race the old comment here claimed: if the
402
527
  // directory is GONE afterwards, a concurrent process reclaimed it (or
@@ -419,7 +544,7 @@ export function withLock(path, fn) {
419
544
  // failure. Clearing it per-iteration made the error message depend on
420
545
  // that detail, which is why it read correctly on one machine and fell
421
546
  // back to the generic timeout on another (ahood-cli#145).
422
- reclaimError = reclaimStaleLock(lockDir, pidFile) ?? reclaimError;
547
+ reclaimError = reclaimStaleLock(lockDir, pidFile, hostFile) ?? reclaimError;
423
548
  }
424
549
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
425
550
  }
@@ -38,8 +38,20 @@ export function safeTool(fn) {
38
38
  return { content: [{ type: "text", text: JSON.stringify(result ?? null) }] };
39
39
  }
40
40
  catch (error) {
41
- const message = error instanceof Error ? error.message : String(error);
42
- const body = { error: message, error_code: errorCodeFor(error) };
41
+ const code = errorCodeFor(error);
42
+ let message = error instanceof Error ? error.message : String(error);
43
+ // ahood-cli#159 / ahood#313 Phase 5. The registry's 401 body is always
44
+ // the byte-identical `{ error: "Unauthorized" }` (lib/api-auth.ts) --
45
+ // correct, but not actionable: an MCP client here is a coding agent
46
+ // reading this JSON, and "Unauthorized" names no remedy. The CLI's own
47
+ // top-level handler already prints the fix (src/index.ts: "Run `ahood
48
+ // login` first (or set AHOOD_TOKEN)."); append the same sentence here
49
+ // verbatim so both surfaces say the same thing rather than two
50
+ // near-copies drifting apart. Only auth_error: usage/not-found/network
51
+ // errors carry their own already-actionable text.
52
+ if (code === "auth_error")
53
+ message += " Run `ahood login` first (or set AHOOD_TOKEN).";
54
+ const body = { error: message, error_code: code };
43
55
  return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
44
56
  }
45
57
  };