@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.
- package/README.md +1 -1
- package/dist/commands/add.js +79 -44
- package/dist/commands/read.js +27 -1
- package/dist/commands/remove.js +54 -11
- package/dist/commands/snap.js +140 -35
- package/dist/commands/update.js +48 -2
- package/dist/flags.js +100 -8
- package/dist/help.js +22 -8
- package/dist/http.js +12 -19
- package/dist/lockfile.js +268 -34
- package/dist/terminal-safe.js +62 -0
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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> [
|
|
350
|
-
summary: "
|
|
351
|
-
desc: "
|
|
352
|
-
"
|
|
353
|
-
"
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|