@ahood/cli 0.8.8 → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  import { LOCKFILE_PATH, parseOwnerSkill } from "../spec.js";
2
2
  import { readLockfile } from "../lockfile.js";
3
- import { add, fetchVersionMeta, updateMcpEntry } from "./add.js";
3
+ import { add, fetchVersionMeta, readMcpConfig, updateMcpEntry } from "./add.js";
4
4
  import { UsageError } from "../usage-error.js";
5
5
  const USAGE = "Usage: ahood skill update [<owner>/<skill> ...] [--dry-run] [--json]";
6
6
  async function previewSkill(ownerSlashSkill, currentVersion) {
@@ -15,6 +15,40 @@ async function previewSkill(ownerSlashSkill, currentVersion) {
15
15
  changelog_md: upToDate ? null : meta.changelog_md ?? null,
16
16
  };
17
17
  }
18
+ // Whether .mcp.json currently holds something that could actually start this
19
+ // skill's server (ahood-cli#139). Anything ahood itself writes is a non-empty
20
+ // JSON object -- buildMcpServerConfig always emits at least a `command` or a
21
+ // `url` -- so a missing key, `null`, `{}`, or a bare scalar all mean the same
22
+ // thing operationally: the server is gone, whatever the lockfile still pins.
23
+ // An emptied entry is reported broken but is NOT self-healed by overwriting:
24
+ // falling through hands it to updateMcpEntry, whose fingerprint check refuses
25
+ // it (before any download or prompt) with the "delete it by hand, then
26
+ // `ahood skill add`" message. ahood never writes `{}` or `null`, so something
27
+ // else did, and quietly stamping over an entry ahood cannot prove is its own
28
+ // is exactly what that check exists to prevent -- an actionable refusal beats
29
+ // both a silent overwrite and today's false "already up to date".
30
+ //
31
+ // Deliberately narrow: an entry that is present and non-empty but doesn't
32
+ // match its recorded fingerprint is NOT reported as broken here. That's a
33
+ // hand-edit, `update` has always left those alone when the pin was already at
34
+ // latest, and turning every locally-tweaked entry into a per-entry failure
35
+ // would be a regression of its own.
36
+ //
37
+ // readMcpConfig() throws on an unreadable or malformed .mcp.json rather than
38
+ // being caught and treated as "no entry": update.ts's per-entry catch turns
39
+ // that into one `Failed to update <skill>: ...` line and a non-zero exit
40
+ // without stopping the rest of the batch, which is what the version-differs
41
+ // path has always done (updateMcpEntry reads the same file through the same
42
+ // function). Swallowing it here would instead print "already up to date"
43
+ // about an entry sitting in a file nothing can parse -- the same false
44
+ // all-clear #139 is about.
45
+ function mcpEntryIsIntact(skill) {
46
+ const mcpServers = readMcpConfig().mcpServers;
47
+ if (!Object.prototype.hasOwnProperty.call(mcpServers, skill))
48
+ return false;
49
+ const entry = mcpServers[skill];
50
+ return typeof entry === "object" && entry !== null && Object.keys(entry).length > 0;
51
+ }
18
52
  function padRow(cells, widths) {
19
53
  return cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
20
54
  }
@@ -109,13 +143,25 @@ export async function update(args) {
109
143
  const meta = await fetchVersionMeta(owner, skill, "latest");
110
144
  if (meta.kind === "mcp") {
111
145
  const currentEntry = lockfile[ownerSlashSkill];
112
- if (currentEntry && currentEntry.version === meta.version) {
146
+ if (currentEntry && currentEntry.version === meta.version && mcpEntryIsIntact(skill)) {
113
147
  // Matches skill/agent update's own "nothing to do" case for a
114
148
  // pin that's already at latest -- not a warning (nothing's
115
149
  // wrong), and, unlike skill/agent (which always blindly
116
150
  // re-extracts even when nothing changed), skipped here
117
151
  // specifically so an up-to-date mcp entry with a secret in its
118
152
  // env never re-triggers a masked prompt for no reason.
153
+ //
154
+ // Conditioned on the entry actually being there (ahood-cli#139):
155
+ // .mcp.json is merged into rather than owned (see CLAUDE.md), so
156
+ // losing an entry to a merge or a hand cleanup is ordinary, and
157
+ // matching versions alone said "already up to date" about a server
158
+ // that no longer existed -- the one kind `update` could not repair,
159
+ // where skill/agent are restored by add() below regardless of
160
+ // version. Falling through runs updateMcpEntry's self-heal (see the
161
+ // "If the entry is missing entirely" case in add.ts), which does
162
+ // have to prompt for secrets there is no on-disk entry to carry
163
+ // forward; on the unattended path its allowNonTtyPrompt: false
164
+ // turns that into a named-variable error instead of a hang.
119
165
  console.log(`${ownerSlashSkill} is already up to date (v${meta.version}).`);
120
166
  continue;
121
167
  }
package/dist/flags.js CHANGED
@@ -11,22 +11,114 @@ import { UsageError } from "./usage-error.js";
11
11
  // value, verbatim, even if it starts with "--" -- this is
12
12
  // the unambiguous escape hatch for values that genuinely
13
13
  // start with "--" (e.g. a tagline like "--fast and cheap").
14
+ // A repeated value flag is refused rather than resolved. `flagValue` used to
15
+ // return the FIRST match and stop, while the stripping below skipped EVERY
16
+ // occurrence's neighbour, so `snap search foo --tags a --tags bar` searched for
17
+ // "foo" tagged "a" and deleted "bar" from the query outright -- the user got
18
+ // results for a command they did not type, at exit 0 (ahood-cli#136). Both
19
+ // last-wins and first-wins are silent guesses about which fragment of a
20
+ // command reassembled from shell history is the stale one; the CLI already
21
+ // refuses flags it doesn't recognize, and refusing here is the only option that
22
+ // cannot discard a value the user typed. The cost is one retry with the
23
+ // duplicate deleted, which is also the edit the user has to make anyway.
24
+ function assertFirstOccurrence(seen, flag) {
25
+ if (seen.has(flag)) {
26
+ throw new UsageError(`${flag} given more than once. Pass it once -- repeating it would silently discard one of the values` +
27
+ // Safe to say unconditionally for --tags: every --tags in this CLI
28
+ // (publish/edit/snap create/list/search) is one comma-separated value,
29
+ // and "it accumulates" is the misconception that produces the repeat.
30
+ (flag === "--tags" ? `; pass multiple tags as one comma-separated value (--tags a,b)` : "") +
31
+ `.`);
32
+ }
33
+ seen.add(flag);
34
+ }
14
35
  export function flagValue(args, flag) {
15
36
  const prefix = `${flag}=`;
37
+ const seen = new Set();
38
+ let found;
16
39
  for (let i = 0; i < args.length; i++) {
17
40
  const arg = args[i];
18
41
  if (arg.startsWith(prefix)) {
19
- return arg.slice(prefix.length);
42
+ assertFirstOccurrence(seen, flag);
43
+ found = arg.slice(prefix.length);
44
+ continue;
20
45
  }
21
46
  if (arg === flag) {
22
47
  const value = args[i + 1];
23
48
  if (value === undefined || value.startsWith("--")) {
24
49
  throw new UsageError(`${flag} requires a value.`);
25
50
  }
26
- return value;
51
+ assertFirstOccurrence(seen, flag);
52
+ found = value;
53
+ }
54
+ }
55
+ return found;
56
+ }
57
+ // Returns the tokens left over after stripping the flags a command declares:
58
+ // `booleanFlags` stand alone (--json), `valueFlags` consume the following token
59
+ // and also accept the --flag=value form. Whatever remains is something the
60
+ // command does NOT accept -- an unrecognized "--" flag or a stray positional --
61
+ // and it's the caller's job to reject it (or, for a query/id command, to
62
+ // interpret it as the positional).
63
+ //
64
+ // Lives here rather than inline in each command because hand-rolling this check
65
+ // per command is exactly how `snap list` ended up with no check at all while
66
+ // its sibling `snap search` had one, so `snap list --tag ci` (singular typo)
67
+ // issued an UNFILTERED request and printed every snap as though it were the
68
+ // filtered set (ahood-cli#135). parseSearchQuery below is now a caller of this
69
+ // helper rather than a second copy of it (folded in by ahood-cli#136, whose bug
70
+ // existed in both copies); tagsSnap still carries its own inline copy -- fold it
71
+ // in when the queued snap-parsing fixes (ahood-cli#137/#138) next touch it,
72
+ // rather than growing a third variant again.
73
+ export function unrecognizedArgs(args, booleanFlags, valueFlags) {
74
+ return unrecognizedIndices(args, booleanFlags, valueFlags).map((i) => args[i]);
75
+ }
76
+ // The same stripping rules as unrecognizedArgs, reporting WHERE the leftovers
77
+ // sat rather than what they were -- and the primitive the two share, so there
78
+ // is still exactly one copy of those rules (the whole point of #135's helper;
79
+ // a second hand-rolled variant is the mistake it exists to stop).
80
+ //
81
+ // `snap create` needs the positions, not just the tokens: its leftovers are the
82
+ // words of a freeform note, so "starts with --" is the only thing it can reject
83
+ // outright, and the one remaining signal that a bare token was misplaced is that
84
+ // the leftovers sit on BOTH sides of a flag the command consumed -- which is
85
+ // precisely `snap create "note" --tags deploy bugfix` folding "bugfix" into the
86
+ // note body (ahood-cli#134). Positions are the only way to see that gap.
87
+ export function unrecognizedIndices(args, booleanFlags, valueFlags) {
88
+ const indices = [];
89
+ const seen = new Set();
90
+ // Which index (if any) the value flag just seen actually consumed. The test
91
+ // used to be `valueFlags.includes(args[i - 1])`, which stripped the neighbour
92
+ // of EVERY occurrence -- so a repeated --tags ate a word nothing had read
93
+ // (ahood-cli#136). Tracking the consuming occurrence means exactly one token
94
+ // per accepted flag disappears, and assertFirstOccurrence refuses the repeat
95
+ // with the same message flagValue gives, so a caller that reaches this helper
96
+ // first can't report the duplicate as "Unknown flag: --tags" instead.
97
+ let consumedAt = -1;
98
+ for (let i = 0; i < args.length; i++) {
99
+ const arg = args[i];
100
+ if (i === consumedAt)
101
+ continue;
102
+ if (booleanFlags.includes(arg))
103
+ continue;
104
+ if (valueFlags.includes(arg)) {
105
+ assertFirstOccurrence(seen, arg);
106
+ // Same swallow-protection as flagValue: a following "--" token is not
107
+ // this flag's value, it's the next flag, so leave it to be judged on its
108
+ // own (flagValue throws "requires a value" for it first in every caller).
109
+ const next = args[i + 1];
110
+ if (next !== undefined && !next.startsWith("--"))
111
+ consumedAt = i + 1;
112
+ continue;
113
+ }
114
+ const equalsForm = valueFlags.find((f) => arg.startsWith(`${f}=`));
115
+ if (equalsForm !== undefined) {
116
+ assertFirstOccurrence(seen, equalsForm);
117
+ continue;
27
118
  }
119
+ indices.push(i);
28
120
  }
29
- return undefined;
121
+ return indices;
30
122
  }
31
123
  // Shared by every "<verb> <query> [--json] [--limit <n>]" command (skill
32
124
  // search, snap search) -- factored out after this exact logic was
@@ -43,11 +135,11 @@ export function flagValue(args, flag) {
43
135
  // a flag that command doesn't implement instead of silently ignoring it and
44
136
  // returning unfiltered results.
45
137
  export function parseSearchQuery(args, usage, valueFlags = []) {
46
- const stripped = ["--limit", ...valueFlags];
47
- const queryParts = args.filter((a, i) => a !== "--json" &&
48
- !stripped.includes(a) &&
49
- !stripped.some((f) => a.startsWith(`${f}=`)) &&
50
- !stripped.includes(args[i - 1]));
138
+ // Delegated to unrecognizedArgs rather than re-filtering here: this was the
139
+ // second hand-rolled copy of those stripping rules, and it carried the same
140
+ // repeated-flag bug (ahood-cli#136) that the first one did -- which is the
141
+ // duplication hazard #135's helper was extracted to end.
142
+ const queryParts = unrecognizedArgs(args, ["--json"], ["--limit", ...valueFlags]);
51
143
  const unknownFlag = queryParts.find((a) => a.startsWith("--"));
52
144
  if (unknownFlag)
53
145
  throw new UsageError(`Unknown flag: ${unknownFlag}\n${usage}`);
package/dist/help.js CHANGED
@@ -283,14 +283,17 @@ export const SNAP_COMMANDS_HELP = [
283
283
  summary: "Capture a new private snap, from an argument or piped stdin.",
284
284
  desc: "Capture a new private snap. Pass the content as an argument, or omit it and pipe content on stdin " +
285
285
  "(e.g. `echo \"...\" | ahood snap create`, or a piped heredoc) -- useful for capturing a whole session's " +
286
- "worth of freeform text at once. Prints the new snap's id on success.",
286
+ "worth of freeform text at once. Prints the new snap's id on success. An unrecognized flag is refused " +
287
+ "rather than folded into the note; pass content that itself starts with `--` after a bare `--`.",
287
288
  flags: [
288
289
  "--tags <comma,separated> Attach tags to the new snap.",
289
290
  "--json Emit {id, created_at} instead of just the bare id.",
291
+ "-- End of options: everything after it is content, verbatim.",
290
292
  ],
291
293
  examples: [
292
294
  'ahood snap create "Debugged the flaky CI step, root cause was a race in the cache key." --tags deploy,bugfix',
293
295
  'echo "..." | ahood snap create',
296
+ 'ahood snap create -- "--json broke the parser"',
294
297
  ],
295
298
  },
296
299
  {
@@ -346,13 +349,24 @@ export const SNAP_COMMANDS_HELP = [
346
349
  flags: ["--yes Skip the interactive confirmation, for scripts/CI."],
347
350
  },
348
351
  {
349
- usage: "ahood snap tags <id> [tag1,tag2,...] [--json]",
350
- summary: "Replace a snap's tags (omit the tag list, or pass an empty one, to clear all tags).",
351
- desc: "Replace the full set of tags on a snap -- this is not a merge, so any existing tags not listed are " +
352
- "dropped. Omitting the tag list entirely, or passing an empty string, clears all tags. No confirmation " +
353
- "prompt: unlike remove/unshare this only touches metadata, never the snap's content or shareability.",
354
- flags: ["--json Emit {id, tags} instead of a plain-text summary."],
355
- examples: ["ahood snap tags snap_123 deploy,bugfix", 'ahood snap tags snap_123 ""'],
352
+ usage: "ahood snap tags <id> [tag ...] [--clear] [--json]",
353
+ summary: "Print a snap's tags, or replace them with the tags given.",
354
+ desc: "With no tag list, prints the snap's current tags and changes nothing. With a tag list, replaces the " +
355
+ "full set -- this is not a merge, so any existing tags not listed are dropped. Tags may be given as " +
356
+ "separate arguments, comma-separated, or both; quote a tag that contains a space. Clearing every tag " +
357
+ "is explicit: pass --clear or an empty string. Each tag prints quoted, so one multi-word tag never " +
358
+ "looks like two. No confirmation prompt: unlike remove/unshare this only touches metadata, never the " +
359
+ "snap's content or shareability, and the tags it replaces can be read back first.",
360
+ flags: [
361
+ "--clear Remove every tag from the snap.",
362
+ "--json Emit {id, tags} instead of a plain-text summary.",
363
+ ],
364
+ examples: [
365
+ "ahood snap tags snap_123",
366
+ "ahood snap tags snap_123 deploy bugfix",
367
+ "ahood snap tags snap_123 deploy,bugfix",
368
+ "ahood snap tags snap_123 --clear",
369
+ ],
356
370
  },
357
371
  ];
358
372
  // Exported so other consumers of the command list (e.g. shell completion) can
package/dist/http.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getApiUrl } from "./config.js";
2
2
  import { resolveToken } from "./credentials.js";
3
+ import { sanitizeForTerminal } from "./terminal-safe.js";
3
4
  import { CLI_NAME, CLI_VERSION } from "./version.js";
4
5
  export class ApiError extends Error {
5
6
  status;
@@ -31,8 +32,12 @@ export async function apiFetch(path, init = {}) {
31
32
  });
32
33
  }
33
34
  catch (error) {
34
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
35
- const message = error instanceof Error ? error.message : String(error);
35
+ // The `instanceof Error` guard on the cause is load-bearing, not defensive
36
+ // noise: `cause` is `unknown`, and optional-chaining into `.message`
37
+ // instead would print a bare "undefined" as the reason a request failed
38
+ // (ahood-cli#129). Dropping the reason entirely is the better failure mode.
39
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${sanitizeForTerminal(error.cause.message)}` : "";
40
+ const message = sanitizeForTerminal(error instanceof Error ? error.message : error);
36
41
  throw new NetworkError(`Request to ${getApiUrl()}${path} failed (${message}${cause})`);
37
42
  }
38
43
  }
@@ -44,23 +49,8 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
44
49
  // sits on every apiJson call in the CLI -- including `add`, which prints
45
50
  // server-sourced text immediately before a masked secret prompt, the exact
46
51
  // moment a cursor-movement/line-clear sequence can repaint what the user sees.
52
+ // See src/terminal-safe.ts for what that substitution does and why.
47
53
  //
48
- // Control characters are replaced with a space rather than deleted so that
49
- // stripping can't silently glue two tokens into one misleading word.
50
- //
51
- // Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
52
- // deliberately flattened too: a forged prompt line ("...\n\nEnter your token:")
53
- // needs no escape sequence at all, just a newline, so preserving them would
54
- // leave the spoofing half of the threat model open while closing the escape
55
- // half. The readability cost is small because anything reaching this branch is
56
- // already capped at MAX_ERROR_MESSAGE_LENGTH -- a few lines' worth of text, not
57
- // a stack trace.
58
- //
59
- // Known duplication: this is the same substitution as `sanitizeForTerminal` in
60
- // src/commands/add.ts (ahood-cli#122). It is copied rather than shared on
61
- // purpose -- promoting a shared helper means editing add.ts's call sites, which
62
- // is follow-up refactor work kept out of this security fix; see ahood-cli#127.
63
- const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
64
54
  // Defense in depth (ahood-cli#31): the API is expected to never forward a raw
65
55
  // upstream error body (an HTML block page from a proxy/WAF, a giant stack
66
56
  // trace, etc.) into `error`, but this CLI shouldn't trust that unconditionally
@@ -69,8 +59,11 @@ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
69
59
  // a normal error string is replaced with a short, safe summary instead.
70
60
  export function sanitizeErrorMessage(message) {
71
61
  const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
62
+ // The length argument is belt-and-braces, not behavior: this branch already
63
+ // guarantees `message.length <= MAX_ERROR_MESSAGE_LENGTH`, and an oversized
64
+ // body takes the generic-summary path below instead.
72
65
  if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
73
- return message.replace(CONTROL_CHARACTERS, " ");
66
+ return sanitizeForTerminal(message, MAX_ERROR_MESSAGE_LENGTH);
74
67
  // No preview of the raw content: an HTML-shaped body's first bytes are
75
68
  // exactly where infra details (e.g. a Cloudflare block page's title, Ray
76
69
  // ID) live, so a "helpful" excerpt would leak the same details this