@ahood/cli 0.5.0 → 0.5.2

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/help.js CHANGED
@@ -71,7 +71,11 @@ export const SKILL_COMMANDS_HELP = [
71
71
  {
72
72
  usage: "ahood skill add <owner>/<skill>[@version]",
73
73
  summary: "Install a skill into .claude/skills/, pinned in the lockfile.",
74
- desc: "Install a skill into .claude/skills/, pinned in the lockfile.",
74
+ desc: "Install a skill into .claude/skills/, pinned in the lockfile. An artifact published with --kind agent " +
75
+ "installs instead as a single file at .claude/agents/<owner>@<skill>.md (Claude Code's own subagent " +
76
+ "loader scans .claude/agents/*.md as flat files), not a .claude/skills/ directory. An artifact published " +
77
+ "with --kind mcp installs by merging an entry into .mcp.json instead, prompting for any secret " +
78
+ "environment variables its server.json declares that aren't already set in your shell.",
75
79
  examples: ["ahood skill add alice/pdf-tools", "ahood skill add alice/pdf-tools@1.2.0"],
76
80
  },
77
81
  {
@@ -143,20 +147,23 @@ export const SKILL_COMMANDS_HELP = [
143
147
  examples: ["ahood skill init pdf-tools", "ahood skill init"],
144
148
  },
145
149
  {
146
- usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]",
147
- summary: "Publish a new version of a skill from a folder containing SKILL.md, creating the skill first if it doesn't already exist.",
148
- desc: "Publish a new version of a skill from a folder containing SKILL.md. If the skill doesn't exist yet, this " +
149
- "creates it first -- pass --name (required in that case) and optionally --tagline/--tags/--license/--homepage/--repository. " +
150
+ usage: "ahood skill publish <owner>/<skill>@<version> [--path <dir>] [--kind skill|agent|mcp] [--name <text>] [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--homepage <url>] [--repository <url>] [--json]",
151
+ summary: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json, creating the skill first if it doesn't already exist.",
152
+ desc: "Publish a new version of a skill, agent, or mcp server manifest from a folder containing SKILL.md, AGENT.md, or server.json. If the artifact " +
153
+ "doesn't exist yet, this creates it first -- pass --name to set its display name, and --kind agent or --kind mcp if " +
154
+ "publishing an AGENT.md or server.json (auto-detected from the folder contents when only one of SKILL.md/AGENT.md/server.json is present). " +
155
+ "--name is required when creating; optionally also pass --tagline/--tags/--license/--homepage/--repository. " +
150
156
  "Processing happens server-side after upload; this command polls and reports the final published/failed status. " +
151
157
  "The legacy form `ahood skill publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. " +
152
158
  "Every flag also accepts the --flag=value form, needed when a value itself starts with --.",
153
159
  flags: [
154
- "--name <text> Required only when creating the skill on this publish.",
160
+ "--kind skill|agent|mcp What kind of artifact this is. Auto-detected from SKILL.md/AGENT.md/server.json when omitted.",
161
+ "--name <text> Required only when creating the artifact on this publish.",
155
162
  "--tagline <text> Short one-line description, used only when creating.",
156
163
  "--tags <comma,separated> Initial tags, used only when creating.",
157
164
  "--license <id> An SPDX license identifier, e.g. MIT, used only when creating.",
158
- "--homepage <url> The skill's homepage URL, used only when creating.",
159
- "--repository <url> The skill's source repository URL, used only when creating.",
165
+ "--homepage <url> The artifact's homepage URL, used only when creating.",
166
+ "--repository <url> The artifact's source repository URL, used only when creating.",
160
167
  "--json Suppress human progress lines; print one {version,status,...} JSON object on completion (or {error} on failure).",
161
168
  ],
162
169
  examples: [
@@ -278,10 +285,74 @@ export function formatCommandHelp(entry) {
278
285
  lines.push("", "Examples:", ...entry.examples.map((e) => ` ${e}`));
279
286
  return lines.join("\n");
280
287
  }
288
+ const FALLBACK_TERMINAL_WIDTH = 80;
289
+ // Piped/redirected output (a script capturing `ahood skill --help`, a test)
290
+ // has no TTY and so no column count -- falls back to a fixed width rather
291
+ // than wrapping to whatever width happened to be inherited, so scripted use
292
+ // gets stable, repeatable output.
293
+ function terminalWidth() {
294
+ const columns = process.stdout.columns;
295
+ return columns && columns > 20 ? columns : FALLBACK_TERMINAL_WIDTH;
296
+ }
297
+ // Greedy word wrap -- doesn't split words, so a single word longer than
298
+ // `width` is left on its own overlength line rather than being cut mid-word.
299
+ function wrapText(text, width) {
300
+ if (width < 10)
301
+ return [text];
302
+ const words = text.split(" ");
303
+ const lines = [];
304
+ let current = "";
305
+ for (const word of words) {
306
+ const candidate = current ? `${current} ${word}` : word;
307
+ if (candidate.length > width && current) {
308
+ lines.push(current);
309
+ current = word;
310
+ }
311
+ else {
312
+ current = candidate;
313
+ }
314
+ }
315
+ if (current)
316
+ lines.push(current);
317
+ return lines;
318
+ }
319
+ // Longest usage string an aligned label column will stretch to accommodate
320
+ // -- past this, one outlier (e.g. `skill publish`'s usage line, with every
321
+ // flag spelled out) would otherwise push every other row's description
322
+ // column out with it, which is what made `ahood skill --help` wrap to
323
+ // 300+ characters per line with no relation to the terminal's actual width
324
+ // (ahood-cli#81). A row whose usage is longer than this gets its summary on
325
+ // its own indented line instead of sharing the row.
326
+ const MAX_LABEL_COLUMN = 50;
327
+ // Renders a two-column command table (usage on the left, one-line summary on
328
+ // the right, aligned and wrapped to the terminal width) -- shared by
329
+ // formatSkillHelp/formatGroupHelp/formatHelp so all three `--help` listings
330
+ // wrap the same way.
331
+ function formatCommandTable(rows) {
332
+ const longest = Math.max(...rows.map((r) => r.usage.length));
333
+ const labelWidth = Math.min(longest, MAX_LABEL_COLUMN);
334
+ const columnWidth = labelWidth + 4; // 2-space left margin + 2-space gutter after the label
335
+ const summaryWidth = Math.max(20, terminalWidth() - columnWidth);
336
+ const indent = " ".repeat(columnWidth);
337
+ const lines = [];
338
+ for (const row of rows) {
339
+ const summaryLines = wrapText(row.summary, summaryWidth);
340
+ if (row.usage.length <= labelWidth) {
341
+ lines.push(` ${row.usage.padEnd(labelWidth + 2)}${summaryLines[0]}`);
342
+ }
343
+ else {
344
+ lines.push(` ${row.usage}`);
345
+ lines.push(`${indent}${summaryLines[0]}`);
346
+ }
347
+ for (const continuation of summaryLines.slice(1)) {
348
+ lines.push(`${indent}${continuation}`);
349
+ }
350
+ }
351
+ return lines;
352
+ }
281
353
  // `ahood skill --help` -- the group-level listing for every skill verb.
282
354
  export function formatSkillHelp() {
283
- const width = Math.max(...SKILL_COMMANDS_HELP.map((c) => usageWithAliases(c).length));
284
- const lines = SKILL_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
355
+ const lines = formatCommandTable(SKILL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
285
356
  return [
286
357
  "ahood skill -- manage skills in the ahood registry",
287
358
  "",
@@ -293,8 +364,7 @@ export function formatSkillHelp() {
293
364
  }
294
365
  // `ahood group --help` -- the group-level listing for every group verb.
295
366
  export function formatGroupHelp() {
296
- const width = Math.max(...GROUP_COMMANDS_HELP.map((c) => usageWithAliases(c).length));
297
- const lines = GROUP_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`);
367
+ const lines = formatCommandTable(GROUP_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })));
298
368
  return [
299
369
  "ahood group -- create private groups, invite members, and share skills with them",
300
370
  "",
@@ -312,12 +382,11 @@ export function formatHelp() {
312
382
  const skillGroupSummary = "Search, install, and publish skills -- run `ahood skill --help` for the full list.";
313
383
  const groupGroupUsage = "ahood group <command>";
314
384
  const groupGroupSummary = "Create private groups and share skills with them -- run `ahood group --help` for the full list.";
315
- const width = Math.max(...TOP_LEVEL_COMMANDS_HELP.map((c) => usageWithAliases(c).length), skillGroupUsage.length, groupGroupUsage.length);
316
- const lines = [
317
- ...TOP_LEVEL_COMMANDS_HELP.map((c) => ` ${usageWithAliases(c).padEnd(width + 2)}${c.summary}`),
318
- ` ${skillGroupUsage.padEnd(width + 2)}${skillGroupSummary}`,
319
- ` ${groupGroupUsage.padEnd(width + 2)}${groupGroupSummary}`,
320
- ];
385
+ const lines = formatCommandTable([
386
+ ...TOP_LEVEL_COMMANDS_HELP.map((c) => ({ usage: usageWithAliases(c), summary: c.summary })),
387
+ { usage: skillGroupUsage, summary: skillGroupSummary },
388
+ { usage: groupGroupUsage, summary: groupGroupSummary },
389
+ ]);
321
390
  return [
322
391
  "ahood -- CLI for the ahood skills registry (https://ahood.vercel.app)",
323
392
  "",
package/dist/lockfile.js CHANGED
@@ -15,20 +15,29 @@ export function readLockfile(path) {
15
15
  throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
16
16
  }
17
17
  }
18
- function writeLockfile(path, lockfile) {
18
+ // Generalized so callers other than the lockfile itself (e.g. add.ts's
19
+ // .mcp.json merge, which sits right next to the lockfile and can hold other
20
+ // servers' secrets) get the same crash-safety guarantee instead of a bare
21
+ // writeFileSync that risks truncating the file on interruption.
22
+ export function writeJsonFileAtomic(path, data) {
19
23
  mkdirSync(dirname(path), { recursive: true });
20
24
  // Write-then-rename so a process interrupted mid-write never leaves a
21
- // truncated lockfile on disk -- a reader always sees either the old or the
22
- // new complete content, never a partial one.
25
+ // truncated file on disk -- a reader always sees either the old or the new
26
+ // complete content, never a partial one.
23
27
  const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
24
- writeFileSync(tmpPath, JSON.stringify(lockfile, null, 2) + "\n");
28
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
25
29
  renameSync(tmpPath, path);
26
30
  }
31
+ function writeLockfile(path, lockfile) {
32
+ writeJsonFileAtomic(path, lockfile);
33
+ }
27
34
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
28
- // so two concurrent `ahood add`/`remove` invocations against the same
35
+ // so two concurrent `ahood skill add`/`remove` invocations against the same
29
36
  // project don't race a read-modify-write and silently drop one another's
30
- // entry.
31
- function withLock(path, fn) {
37
+ // entry. Exported so other same-directory JSON files with the same
38
+ // read-modify-write shape (e.g. add.ts's .mcp.json merge) can reuse it
39
+ // instead of duplicating the pattern.
40
+ export function withLock(path, fn) {
32
41
  mkdirSync(dirname(path), { recursive: true });
33
42
  const lockDir = `${path}.lock`;
34
43
  const deadline = Date.now() + 5000;
@@ -0,0 +1,74 @@
1
+ import { createInterface } from "node:readline";
2
+ // Masked secret-value prompt, extending confirm.ts's TTY-safe "close" race
3
+ // (see confirm.ts's own comment): the same closed-stdin-never-resolves
4
+ // problem, and the same fix -- a "close" listener rejects if it fires
5
+ // before an answer is captured. Kept as its own module rather than folded
6
+ // into confirm.ts because that module's contract is yes/no, not a typed
7
+ // value, and because masking requires raw-mode keystroke handling that a
8
+ // plain rl.question() call cannot do (readline always echoes verbatim when
9
+ // its input is a TTY).
10
+ export function promptSecret(promptText) {
11
+ const stdin = process.stdin;
12
+ if (!stdin.isTTY) {
13
+ // Non-interactive stdin (piped input, CI, or these tests' fake
14
+ // Readable): fall back to an ordinary line-buffered question. There is
15
+ // no keystroke stream to mask in this mode -- the whole line arrives as
16
+ // one chunk -- and masking would only hide a value that's already
17
+ // sitting unmasked in whatever produced the pipe.
18
+ const rl = createInterface({ input: stdin, output: process.stdout });
19
+ return new Promise((resolve, reject) => {
20
+ let settled = false;
21
+ rl.once("close", () => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ reject(new Error("Prompt was not answered (stdin closed)."));
26
+ });
27
+ rl.question(promptText, (answer) => {
28
+ settled = true;
29
+ rl.close();
30
+ resolve(answer);
31
+ });
32
+ });
33
+ }
34
+ process.stdout.write(promptText);
35
+ stdin.setRawMode(true);
36
+ stdin.resume();
37
+ stdin.setEncoding("utf8");
38
+ return new Promise((resolve, reject) => {
39
+ let value = "";
40
+ function cleanup() {
41
+ stdin.removeListener("data", onData);
42
+ stdin.setRawMode(false);
43
+ stdin.pause();
44
+ }
45
+ function onData(chunk) {
46
+ for (const char of chunk) {
47
+ if (char === "\n" || char === "\r") {
48
+ cleanup();
49
+ process.stdout.write("\n");
50
+ resolve(value);
51
+ return;
52
+ }
53
+ if (char === "\u0003") {
54
+ // Ctrl-C
55
+ cleanup();
56
+ process.stdout.write("\n");
57
+ reject(new Error("Prompt cancelled (Ctrl-C)."));
58
+ return;
59
+ }
60
+ if (char === "\u007f" || char === "\b") {
61
+ // Backspace/delete
62
+ if (value.length > 0) {
63
+ value = value.slice(0, -1);
64
+ process.stdout.write("\b \b");
65
+ }
66
+ continue;
67
+ }
68
+ value += char;
69
+ process.stdout.write("*");
70
+ }
71
+ }
72
+ stdin.on("data", onData);
73
+ });
74
+ }
package/dist/spec.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { UsageError } from "./usage-error.js";
2
3
  // Owner and skill segments become filesystem path components (.claude/skills/<owner>/<skill>)
3
4
  // and URL path segments, so both are restricted to a safe charset that cannot contain "..",
4
5
  // "/", "\", or a leading dot -- this is what closes the path-traversal hole in add/remove
@@ -17,19 +18,19 @@ export const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-
17
18
  const MAX_SEGMENT_LENGTH = { owner: 32, skill: 64 };
18
19
  export function validateSegment(value, kind, spec) {
19
20
  if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
20
- throw new Error(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
21
+ throw new UsageError(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
21
22
  }
22
23
  const maxLength = MAX_SEGMENT_LENGTH[kind];
23
24
  if (value.length > maxLength) {
24
- throw new Error(`Invalid ${kind} in "${spec.slice(0, 60)}${spec.length > 60 ? "..." : ""}" -- ${kind} segment is too long (${value.length} characters; must be at most ${maxLength}).`);
25
+ throw new UsageError(`Invalid ${kind} in "${spec.slice(0, 60)}${spec.length > 60 ? "..." : ""}" -- ${kind} segment is too long (${value.length} characters; must be at most ${maxLength}).`);
25
26
  }
26
27
  }
27
28
  export function parseOwnerSkill(spec, usage) {
28
29
  if (!spec)
29
- throw new Error(usage);
30
+ throw new UsageError(usage);
30
31
  const parts = spec.split("/");
31
32
  if (parts.length !== 2 || !parts[0] || !parts[1])
32
- throw new Error(usage);
33
+ throw new UsageError(usage);
33
34
  const [owner, skill] = parts;
34
35
  validateSegment(owner, "owner", spec);
35
36
  validateSegment(skill, "skill", spec);
@@ -37,15 +38,15 @@ export function parseOwnerSkill(spec, usage) {
37
38
  }
38
39
  export function parseOwnerSkillVersion(spec, usage) {
39
40
  if (!spec)
40
- throw new Error(usage);
41
+ throw new UsageError(usage);
41
42
  const atIndex = spec.lastIndexOf("@");
42
43
  const ownerSkill = atIndex > 0 ? spec.slice(0, atIndex) : spec;
43
44
  const version = atIndex > 0 ? spec.slice(atIndex + 1) : "latest";
44
45
  if (atIndex > 0 && version === "") {
45
- throw new Error(`Missing version after "@" in "${spec}".\n${usage}`);
46
+ throw new UsageError(`Missing version after "@" in "${spec}".\n${usage}`);
46
47
  }
47
48
  if (version !== "latest" && !SEMVER_RE.test(version)) {
48
- throw new Error(`Invalid version "${version}" in "${spec}" -- expected "latest" or a semver like 1.2.3.\n${usage}`);
49
+ throw new UsageError(`Invalid version "${version}" in "${spec}" -- expected "latest" or a semver like 1.2.3.\n${usage}`);
49
50
  }
50
51
  const { owner, skill } = parseOwnerSkill(ownerSkill, usage);
51
52
  return { owner, skill, version };
@@ -67,10 +68,10 @@ export function validateExternalUrl(value, flag) {
67
68
  url = new URL(value);
68
69
  }
69
70
  catch {
70
- throw new Error(`${flag} must be a valid http:// or https:// URL (got "${value}").`);
71
+ throw new UsageError(`${flag} must be a valid http:// or https:// URL (got "${value}").`);
71
72
  }
72
73
  if (url.protocol !== "https:" && url.protocol !== "http:") {
73
- throw new Error(`${flag} must use http:// or https:// (got scheme "${url.protocol}" from "${value}").`);
74
+ throw new UsageError(`${flag} must use http:// or https:// (got scheme "${url.protocol}" from "${value}").`);
74
75
  }
75
76
  }
76
77
  export const SKILLS_ROOT = join(".claude", "skills");
@@ -78,3 +79,26 @@ export const LOCKFILE_PATH = join(".claude", "skills.lock.json");
78
79
  export function skillDir(owner, skill) {
79
80
  return join(SKILLS_ROOT, owner, skill);
80
81
  }
82
+ // Claude Code's own subagent loader scans .claude/agents/*.md as flat files
83
+ // (non-recursive), unlike .claude/skills/<owner>/<skill>/ which nests by
84
+ // owner -- a bare .claude/agents/<skill>.md would let two different owners'
85
+ // same-named agent collide and silently overwrite each other. Joining the
86
+ // owner into the filename with "@" keeps the layout flat (so Claude Code
87
+ // still finds it) while staying collision-resistant: SEGMENT_RE's character
88
+ // class is exactly [a-z0-9._-] (case-insensitive), which does not include
89
+ // "@" -- so "@" can never appear inside a valid owner or skill segment, and
90
+ // the joined filename always contains exactly one "@", at the boundary
91
+ // between the two segments. That makes the join unambiguous for any pair of
92
+ // SEGMENT_RE-valid values, including ones containing hyphens (e.g.
93
+ // owner="al-ice"/skill="bob" joins to "al-ice@bob.md", which cannot also be
94
+ // produced by any other valid owner/skill pair). "@" is a valid filename
95
+ // character on Linux, macOS, and Windows.
96
+ export const AGENTS_ROOT = join(".claude", "agents");
97
+ export function agentPath(owner, skill) {
98
+ return join(AGENTS_ROOT, `${owner}@${skill}.md`);
99
+ }
100
+ // .mcp.json lives at the project root -- Claude Code's own convention for
101
+ // MCP server configuration (unlike .claude/skills or .claude/agents, this
102
+ // file isn't under .claude/ at all, since it's a convention MCP clients
103
+ // broadly share, not one specific to this CLI).
104
+ export const MCP_CONFIG_PATH = ".mcp.json";
@@ -0,0 +1,8 @@
1
+ // Thrown for a usage/validation error caught locally, before any network
2
+ // call -- a missing/malformed argument, an invalid flag value, a bad
3
+ // owner/skill spec, etc. Kept distinct from a plain Error so exit-code.ts's
4
+ // exitCodeFor() can map it to exit code 2 ("usage or validation error", per
5
+ // the README's exit-code table) instead of falling through to the generic 1
6
+ // ("general error") that every other thrown Error gets (ahood-cli#80).
7
+ export class UsageError extends Error {
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",