@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/README.md +91 -5
- package/dist/commands/add.js +69 -11
- package/dist/commands/completion.js +61 -37
- package/dist/commands/diff.js +4 -1
- package/dist/commands/edit.js +3 -1
- package/dist/commands/init.js +90 -10
- package/dist/commands/list.js +11 -5
- package/dist/commands/login.js +63 -3
- package/dist/commands/mcp.js +6 -3
- package/dist/commands/publish.js +35 -5
- package/dist/commands/read.js +3 -1
- package/dist/commands/remove.js +29 -1
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +61 -9
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/help.js +270 -7
- package/dist/http.js +125 -26
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +142 -17
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
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,
|
|
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
|
-
|
|
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
|
|
147
|
-
//
|
|
148
|
-
// (`ahood
|
|
149
|
-
// main()'s generic top-level --help
|
|
150
|
-
|
|
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(
|
|
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
|
|
159
|
-
const suggestion =
|
|
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(
|
|
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(
|
|
167
|
-
console.log(entry ? formatCommandHelp(entry) :
|
|
180
|
+
const entry = findCommandHelp(noun, sub);
|
|
181
|
+
console.log(entry ? formatCommandHelp(entry) : formatKindHelp(noun));
|
|
168
182
|
return;
|
|
169
183
|
}
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
234
|
-
console.log(entry ? formatCommandHelp(entry) :
|
|
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
|
-
|
|
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)
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/mcp/safe-tool.js
CHANGED
|
@@ -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
|
|
42
|
-
|
|
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
|
};
|