@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/dist/lockfile.js CHANGED
@@ -1,5 +1,5 @@
1
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
  export function readLockfile(path) {
4
4
  if (!existsSync(path))
5
5
  return {};
@@ -72,16 +72,83 @@ function sweepStaleTempFiles(path, ownTempName) {
72
72
  }
73
73
  }
74
74
  }
75
+ // How many links deep resolveWriteTarget will follow before declaring a cycle.
76
+ // Same order of magnitude as the kernel's own ceiling (40 on Linux); the exact
77
+ // number doesn't matter, only that a self-referential link terminates with an
78
+ // actionable error instead of spinning.
79
+ const MAX_SYMLINK_HOPS = 32;
80
+ // THE CONTRACT: a symlinked destination is written THROUGH, onto the file the
81
+ // link resolves to, and the link itself is left in place.
82
+ //
83
+ // The alternative -- letting renameSync replace the link with a regular file,
84
+ // which is what this used to do -- is indefensible for .mcp.json in
85
+ // particular: symlinking it at a shared or out-of-tree config is how a user
86
+ // keeps MCP server secrets OUT of the project directory, so replacing the link
87
+ // drops those secrets into precisely the directory the arrangement exists to
88
+ // keep them out of, while the real config silently keeps stale content.
89
+ // ahood-cli#119's mode preservation made that harder to notice rather than
90
+ // easier, since the file left behind carried the target's permissions
91
+ // (ahood-cli#144).
92
+ //
93
+ // Refusing outright (lstat, throw "this must be a regular file") was the other
94
+ // candidate, and was rejected: every caller here READS the same path first,
95
+ // through the link -- add.ts merges the existing .mcp.json, the lockfile
96
+ // writers merge the existing pins -- so a read-modify-write that reads the
97
+ // target and writes anywhere else is incoherent no matter which file ends up
98
+ // holding the result. Refusing would also make a crash-safety implementation
99
+ // detail visible as a hard failure: the plain writeFileSync this replaced
100
+ // followed the link, as does every editor and every shell redirection, and the
101
+ // callers have no idea a symlink is involved at all.
102
+ //
103
+ // lstatSync, never statSync, and deliberately so: statSync follows the link and
104
+ // therefore cannot answer the only question asked here ("is this path itself a
105
+ // link?"). Asking it with statSync is the original bug -- the mode came back
106
+ // from the link's target and was then applied to the regular file that had
107
+ // taken the link's place.
108
+ function resolveWriteTarget(path) {
109
+ let current = path;
110
+ for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) {
111
+ let isLink;
112
+ try {
113
+ isLink = lstatSync(current).isSymbolicLink();
114
+ }
115
+ catch {
116
+ // Nothing at this path: either a brand-new destination, or the dangling
117
+ // end of a chain whose final target doesn't exist yet -- a user who sets
118
+ // the symlink up before the first install. Write there, so the link
119
+ // starts resolving to the file the caller's next read will follow it to.
120
+ return current;
121
+ }
122
+ if (!isLink)
123
+ return current;
124
+ const link = readlinkSync(current);
125
+ current = isAbsolute(link) ? link : resolve(dirname(current), link);
126
+ }
127
+ throw new Error(`Refusing to write ${path}: it resolves through more than ${MAX_SYMLINK_HOPS} symlinks, so the chain loops back on itself. Repoint or delete that link.`);
128
+ }
75
129
  // Generalized so callers other than the lockfile itself (e.g. add.ts's
76
130
  // .mcp.json merge, which sits right next to the lockfile and can hold other
77
131
  // servers' secrets) get the same crash-safety guarantee instead of a bare
78
132
  // writeFileSync that risks truncating the file on interruption.
79
133
  export function writeJsonFileAtomic(path, data) {
80
- mkdirSync(dirname(path), { recursive: true });
134
+ // Resolved before the mkdir so the directory that actually gets created is
135
+ // the resolved target's, not the link's -- a link set up ahead of the first
136
+ // install can point somewhere that doesn't exist yet -- and so that every
137
+ // step below (temp file, rename, chmod, sweep) names the same single file.
138
+ const dest = resolveWriteTarget(path);
139
+ mkdirSync(dirname(dest), { recursive: true });
81
140
  // Write-then-rename so a process interrupted mid-write never leaves a
82
141
  // truncated file on disk -- a reader always sees either the old or the new
83
142
  // complete content, never a partial one.
84
- const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
143
+ //
144
+ // The temp file must be a sibling of the RESOLVED destination rather than of
145
+ // `path`: renameSync cannot cross filesystems (EXDEV), and an out-of-tree
146
+ // target -- a different mount, a home directory on another volume -- is
147
+ // exactly the case a symlinked .mcp.json is used for. Deriving the temp name
148
+ // from `dest` keeps the rename inside one directory and keeps #119's
149
+ // `finally` unlink and #125's sweep pointed at the directory this code
150
+ // actually creates temp files in (ahood-cli#144).
151
+ const tmpPath = `${dest}.tmp-${process.pid}-${process.hrtime.bigint()}`;
85
152
  // The mode the destination must end up with. Preserving an existing file's
86
153
  // permissions matters because .mcp.json is a project-shared config other MCP
87
154
  // clients read: whether it should be 0644 or tighter is its own decision
@@ -89,9 +156,14 @@ export function writeJsonFileAtomic(path, data) {
89
156
  // of the 0600 temp file below (ahood-cli#119). With no destination yet there
90
157
  // is nothing to preserve, so fall back to what a plain writeFileSync would
91
158
  // have produced -- 0666 masked by the process umask.
159
+ //
160
+ // lstatSync rather than statSync even though `dest` is by construction never
161
+ // a symlink (resolveWriteTarget walked until it wasn't): the two agree here,
162
+ // and lstat says so at the call site instead of leaving a link-following stat
163
+ // one refactor away from reintroducing #144.
92
164
  let finalMode;
93
165
  try {
94
- finalMode = statSync(path).mode & 0o777;
166
+ finalMode = lstatSync(dest).mode & 0o777;
95
167
  }
96
168
  catch {
97
169
  finalMode = 0o666 & ~process.umask();
@@ -101,9 +173,9 @@ export function writeJsonFileAtomic(path, data) {
101
173
  // resolved MCP server secrets, and it must never be world-readable, not
102
174
  // even for the instant before the rename (ahood-cli#119).
103
175
  writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
104
- renameSync(tmpPath, path);
176
+ renameSync(tmpPath, dest);
105
177
  try {
106
- chmodSync(path, finalMode);
178
+ chmodSync(dest, finalMode);
107
179
  }
108
180
  catch {
109
181
  // Best-effort, and deliberately after the rename rather than on the temp
@@ -113,7 +185,13 @@ export function writeJsonFileAtomic(path, data) {
113
185
  // already landed -- the caller would roll back a successful install.
114
186
  }
115
187
  try {
116
- sweepStaleTempFiles(path, basename(tmpPath));
188
+ sweepStaleTempFiles(dest, basename(tmpPath));
189
+ // Every version before ahood-cli#144 put its temp file next to the LINK
190
+ // instead of next to the target, so a crash under one of those can have
191
+ // stranded a plaintext-secret orphan there that no later write would ever
192
+ // look at again. One extra sweep of the link's own directory retires them.
193
+ if (dest !== path)
194
+ sweepStaleTempFiles(path, basename(tmpPath));
117
195
  }
118
196
  catch {
119
197
  // Swallowed for the same reason as the chmod above and #119's unlink:
@@ -139,25 +217,116 @@ export function writeJsonFileAtomic(path, data) {
139
217
  function writeLockfile(path, lockfile) {
140
218
  writeJsonFileAtomic(path, lockfile);
141
219
  }
142
- // A lock directory is stale if the PID recorded inside it (written by the
143
- // acquirer below) belongs to a process that's no longer running, per
144
- // isPidDead above. A missing/unparseable pid file is treated as "not stale"
145
- // -- either a lock from before this file existed, or another process is
146
- // still mid-way through acquiring (mkdirSync succeeded, the pid write hasn't
147
- // landed yet) -- safer to wait it out than to reclaim a lock that's actually
148
- // still being set up.
149
- function isLockStale(pidFile) {
220
+ // The pid recorded in a lock directory by its acquirer below, or undefined if
221
+ // there is no readable, plausible one: no pid file at all (a lock from before
222
+ // this file existed, or one whose acquirer is still mid-way through setting it
223
+ // up -- mkdirSync succeeded, the pid write hasn't landed yet), or contents
224
+ // that don't parse as a pid.
225
+ function readLockPid(pidFile) {
150
226
  let pidText;
151
227
  try {
152
228
  pidText = readFileSync(pidFile, "utf-8");
153
229
  }
154
230
  catch {
155
- return false;
231
+ return undefined;
156
232
  }
157
233
  const pid = Number(pidText);
158
- if (!Number.isInteger(pid) || pid <= 0)
159
- return false;
160
- return isPidDead(pid);
234
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
235
+ }
236
+ // A lock directory is stale if the pid recorded inside it belongs to a process
237
+ // that's no longer running, per isPidDead above. No readable pid means "not
238
+ // stale": safer to wait out a lock that may still be being set up than to
239
+ // reclaim one that is actually held.
240
+ function isLockStale(pidFile) {
241
+ const pid = readLockPid(pidFile);
242
+ return pid !== undefined && isPidDead(pid);
243
+ }
244
+ // Deliberately NOT the negation of isLockStale: with no readable pid at all a
245
+ // lock is neither stale nor demonstrably held, and the two callers want
246
+ // opposite answers in that case. Reclaiming needs proof the holder is dead
247
+ // (hence isLockStale), while withLock's deadline branch needs proof that
248
+ // someone is alive before it backs off a reclaim failure it already observed.
249
+ function isLockHeldByLiveProcess(pidFile) {
250
+ const pid = readLockPid(pidFile);
251
+ return pid !== undefined && !isPidDead(pid);
252
+ }
253
+ // Name of the reclaim claim directory created inside a lock directory that is
254
+ // about to be reclaimed. A single well-known name, deliberately not a
255
+ // per-process one: its entire job is to be a name exactly one process can
256
+ // create.
257
+ const RECLAIM_CLAIM = "reclaiming";
258
+ // Reclaiming a stale lock is a claim-verify-remove, not the bare
259
+ // check-then-delete it used to be: two waiters could both judge the SAME dead
260
+ // holder stale off the same snapshot, and then the second one's rmSync would
261
+ // delete the first one's freshly-taken, live lock -- two processes inside the
262
+ // critical section at once, which is the exact lost update on .mcp.json /
263
+ // skills.lock.json that withLock exists to prevent (ahood-cli#141).
264
+ //
265
+ // The claim is a directory created INSIDE the lock directory, so it rides on
266
+ // the same mkdir atomicity the lock itself is built on: exactly one waiter can
267
+ // create it, and the losers fall through to the ordinary wait. Two
268
+ // alternatives were weighed and rejected:
269
+ // - Merely re-reading the pid immediately before the rmSync narrows the
270
+ // window but never closes it; the check and the unlink still aren't atomic.
271
+ // - renameSync-ing the lock directory itself to a unique name makes the
272
+ // rename the claim, but a rename succeeds against WHATEVER directory sits
273
+ // at that path -- a live successor's included -- and it leaves the lock
274
+ // path briefly absent, so a third process can mkdir it and acquire for
275
+ // real. That manufactures two live holders rather than preventing them.
276
+ // This claim never removes or rewrites anything a holder owns and never makes
277
+ // the lock path vanish, so mutual exclusion survives even a claim that turns
278
+ // out to have been a mistake.
279
+ //
280
+ // Winning the claim is what makes the staleness verdict trustworthy, which is
281
+ // why the pid is re-read UNDER it: the directory judged stale a moment ago may
282
+ // already have been reclaimed and retaken by someone alive. Once the claim is
283
+ // held and the pid still reads dead, nothing else can remove that directory --
284
+ // a release by its holder is ruled out by that dead pid, and every other
285
+ // reclaimer is blocked by the claim -- so the verdict still holds at the moment
286
+ // of the rmSync below.
287
+ //
288
+ // Returns the error to remember for withLock's deadline branch (see
289
+ // ahood-cli#140), or undefined when there is nothing worth remembering.
290
+ function reclaimStaleLock(lockDir, pidFile) {
291
+ const claim = join(lockDir, RECLAIM_CLAIM);
292
+ try {
293
+ mkdirSync(claim);
294
+ }
295
+ catch (error) {
296
+ const code = error.code;
297
+ // EEXIST means another waiter is reclaiming this very directory right now
298
+ // and ENOENT that it is already gone; both resolve themselves on the next
299
+ // retry. Anything else (EACCES/EPERM/EROFS on a directory this process
300
+ // cannot write into) is a reclaim that can never succeed, and is what the
301
+ // deadline branch reports instead of a misleading "waiting for the lock".
302
+ if (code === "EEXIST" || code === "ENOENT")
303
+ return undefined;
304
+ return existsSync(lockDir) ? error : undefined;
305
+ }
306
+ if (!isLockStale(pidFile)) {
307
+ // Not the directory that was judged stale after all -- it was reclaimed and
308
+ // retaken while this process looked, or it belongs to a holder that hasn't
309
+ // written its pid yet. Hand the claim back, leaving the lock exactly as it
310
+ // was found.
311
+ try {
312
+ rmSync(claim, { recursive: true, force: true });
313
+ }
314
+ catch {
315
+ // Best effort: the holder's own release takes this directory, claim and
316
+ // all, with it. Worst case is a lock nothing reclaims, which times out
317
+ // with an actionable message rather than corrupting anything -- the same
318
+ // outcome as a process killed between the two syscalls above.
319
+ }
320
+ return undefined;
321
+ }
322
+ try {
323
+ rmSync(lockDir, { recursive: true, force: true });
324
+ }
325
+ catch (error) {
326
+ if (existsSync(lockDir))
327
+ return error;
328
+ }
329
+ return undefined;
161
330
  }
162
331
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
163
332
  // so two concurrent `ahood skill add`/`remove` invocations against the same
@@ -177,6 +346,15 @@ export function withLock(path, fn) {
177
346
  const lockDir = `${path}.lock`;
178
347
  const pidFile = join(lockDir, "pid");
179
348
  const deadline = Date.now() + 5000;
349
+ // Set when the lock was judged stale but could not actually be removed and
350
+ // that directory is still sitting there -- see the reclaim branch below.
351
+ // Reported instead of the generic timeout, because "another process holds
352
+ // the lock" sends the user hunting for a process that provably no longer
353
+ // exists (ahood-cli#140).
354
+ let reclaimError;
355
+ // Whether this process's own pid ever made it into the lock directory --
356
+ // what lets the release below tell its own lock from a successor's.
357
+ let wrotePid = false;
180
358
  for (;;) {
181
359
  try {
182
360
  mkdirSync(lockDir);
@@ -185,6 +363,7 @@ export function withLock(path, fn) {
185
363
  // just without staleness detection for this particular acquisition.
186
364
  try {
187
365
  writeFileSync(pidFile, String(process.pid));
366
+ wrotePid = true;
188
367
  }
189
368
  catch {
190
369
  // non-fatal, see comment above
@@ -194,22 +373,54 @@ export function withLock(path, fn) {
194
373
  catch (error) {
195
374
  if (error.code !== "EEXIST")
196
375
  throw error;
197
- if (isLockStale(pidFile)) {
198
- // Reclaim it. Best-effort removal: if a concurrent process wins the
199
- // race and reclaims (or a live holder finishes and releases) first,
200
- // this just falls through to the normal wait/retry below instead of
201
- // throwing.
202
- try {
203
- rmSync(lockDir, { recursive: true, force: true });
204
- }
205
- catch {
206
- // lost the race -- fall through to wait/retry
207
- }
208
- continue;
209
- }
376
+ // The deadline is tested FIRST so that every retry path is bounded by
377
+ // it. The reclaim branch below used to `continue` straight past both
378
+ // this check and the sleep, so a stale lock this process could not
379
+ // actually remove (rmSync's force:true suppresses only ENOENT, never
380
+ // EACCES/EPERM/EROFS) turned into an unbounded 100%-CPU spin that never
381
+ // threw and never printed anything -- the one thing a CLI built to run
382
+ // unattended in CI must never do (ahood-cli#140).
210
383
  if (Date.now() > deadline) {
384
+ // The remembered failure is re-confirmed here rather than trusted,
385
+ // because a process that DOES have permission (a root/owner ahood in
386
+ // another terminal) can clear that directory and take the lock for
387
+ // real while this one waits -- and "the process that held it is gone"
388
+ // would then be a confident lie about a lock somebody is legitimately
389
+ // holding. Re-confirming asks exactly what the message claims: is
390
+ // there still a directory, and is nobody alive holding it? Identity
391
+ // (inode, birthtime) deliberately isn't used for this: a removed and
392
+ // immediately recreated directory routinely gets the same inode back,
393
+ // so it can't tell a replacement from the original anyway.
394
+ if (reclaimError && existsSync(lockDir) && !isLockHeldByLiveProcess(pidFile)) {
395
+ 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
+ }
211
397
  throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
212
398
  }
399
+ if (isLockStale(pidFile)) {
400
+ // Reclaim it. Two very different conditions can fail that, and only
401
+ // one of them is the race the old comment here claimed: if the
402
+ // directory is GONE afterwards, a concurrent process reclaimed it (or
403
+ // a live holder released it) first, which is harmless -- the retry
404
+ // below simply takes the lock. If it is still THERE, this process
405
+ // cannot remove it at all, and retrying can only fail the same way,
406
+ // so the failure is remembered for the deadline branch above rather
407
+ // than swallowed (ahood-cli#140). It is remembered rather than thrown
408
+ // immediately because a mid-reclaim race can also surface as e.g.
409
+ // ENOTEMPTY with the directory still present, and that one does clear
410
+ // itself on the next attempt.
411
+ //
412
+ // The memory deliberately survives later iterations (hence `??`
413
+ // rather than a plain assignment): a failed recursive removal can take
414
+ // the `pid` file with it (it deletes the contents, then fails on the
415
+ // directory itself -- which of the two happens before the error is an
416
+ // implementation detail that differs between Node versions), and
417
+ // without a pid file isLockStale reports "not stale", so this branch
418
+ // never runs again and there is no second chance to observe the
419
+ // failure. Clearing it per-iteration made the error message depend on
420
+ // that detail, which is why it read correctly on one machine and fell
421
+ // back to the generic timeout on another (ahood-cli#145).
422
+ reclaimError = reclaimStaleLock(lockDir, pidFile) ?? reclaimError;
423
+ }
213
424
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
214
425
  }
215
426
  }
@@ -217,7 +428,30 @@ export function withLock(path, fn) {
217
428
  return fn();
218
429
  }
219
430
  finally {
220
- rmSync(lockDir, { recursive: true, force: true });
431
+ // Releasing used to be an unconditional rmSync, which is only correct for
432
+ // as long as this process still holds the lock it took -- and it does not
433
+ // always. A reclaimer that judged this process dead can delete this lock
434
+ // out from under a running critical section (ahood-cli#141 on one host,
435
+ // and the host-local pid probe above across a container bind mount or a
436
+ // network share), and the unconditional removal then took the SUCCESSOR's
437
+ // lock down with it, turning one lost update into a chain of them.
438
+ // Removing only a directory that still records THIS process's pid keeps
439
+ // the damage to the process that was wronged.
440
+ //
441
+ // A pid file that is unreadable despite having been written is left alone
442
+ // for the same reason: the only thing that removes it is a reclaimer
443
+ // partway through taking this lock, so whatever is at that path is no
444
+ // longer this process's to delete.
445
+ //
446
+ // With no pid of this process's own ever written (the best-effort write
447
+ // above failed) there is nothing to compare against -- and nothing to
448
+ // protect either, since a lock directory with no readable pid is never
449
+ // judged stale and so can never have been reclaimed. Removing it
450
+ // unconditionally is then both safe and necessary: leaving it behind
451
+ // strands a lock that nothing is able to reclaim.
452
+ if (!wrotePid || readLockPid(pidFile) === process.pid) {
453
+ rmSync(lockDir, { recursive: true, force: true });
454
+ }
221
455
  }
222
456
  }
223
457
  export function writeLockfileEntry(path, ownerSlashSkill, entry) {
@@ -0,0 +1,62 @@
1
+ // Control characters are replaced with a space rather than deleted so that
2
+ // stripping can't silently glue two tokens into one misleading word.
3
+ //
4
+ // Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
5
+ // deliberately flattened too (ahood-cli#127): a forged prompt line
6
+ // ("...\n\nEnter your token:") needs no escape sequence at all, just a
7
+ // newline, so preserving them would leave the spoofing half of the threat
8
+ // model open while closing the escape half. The readability cost is small
9
+ // because callers bound what reaches here to a few lines' worth of text, not
10
+ // a stack trace.
11
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
12
+ // Text that reaches the terminal but originates outside this CLI -- a manifest
13
+ // field from a third-party-published archive, an error string forwarded by the
14
+ // API -- can smuggle a terminal control sequence (cursor movement, line-clear)
15
+ // that repaints what the user sees. That matters most in `add`, which prints
16
+ // such text immediately before a masked secret prompt: the exact moment a
17
+ // repaint can convince a user to type a credential into an attacker's message.
18
+ // This strips C0/C1 control characters (including ESC, \x1b) and caps length so
19
+ // a single field can't also flood the terminal.
20
+ //
21
+ // Takes `unknown` rather than `string` because a caller's "string" may be a
22
+ // declared type over an unchecked cast of downloaded JSON, not a guarantee:
23
+ // calling .replace() directly on a `registry_type: 123` would turn a
24
+ // diagnosable message into the raw TypeError ahood-cli#121 removed from that
25
+ // same path (ahood-cli#122).
26
+ export function sanitizeForTerminal(text, maxLength = 200) {
27
+ return String(text).replace(CONTROL_CHARACTERS, " ").slice(0, maxLength);
28
+ }
29
+ // Same C0/C1 range as CONTROL_CHARACTERS above, minus the three characters
30
+ // that carry a document's layout rather than a terminal instruction: tab
31
+ // (\x09), line feed (\x0a) and carriage return (\x0d).
32
+ const CONTROL_CHARACTERS_EXCEPT_LAYOUT = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
33
+ // The document-shaped sibling of sanitizeForTerminal, for the one caller that
34
+ // prints a whole publisher-authored file to a human's terminal
35
+ // (`ahood skill read`, ahood-cli#133) rather than a field interpolated into a
36
+ // CLI-authored message. Two of sanitizeForTerminal's decisions invert at that
37
+ // size, which is why this is a separate function rather than an extra
38
+ // argument on that one -- a flag that flips half of a function's documented
39
+ // behaviour is harder to reason about than two functions with two rationales:
40
+ //
41
+ // - Newlines are preserved. #127 flattens them because a forged prompt line
42
+ // ("...\n\nEnter your token:") needs no escape sequence at all, and that
43
+ // rationale explicitly rests on callers bounding input "to a few lines'
44
+ // worth of text". A markdown document is not that: flattening it yields
45
+ // one unreadable line, destroying the command. The spoofing risk it trades
46
+ // away is also much weaker here -- `read` prints and exits without ever
47
+ // prompting, and a user who typed `ahood skill read` has already been told
48
+ // they are looking at someone else's untrusted prose.
49
+ //
50
+ // - There is no length cap. sanitizeForTerminal's 200 exists so one field
51
+ // can't flood the terminal; capping a document the user explicitly asked
52
+ // to read is its own failure mode, and a hostile one: a publisher could
53
+ // simply push the part worth hiding past the cap and let the CLI truncate
54
+ // it away, turning a safety measure into a concealment primitive. Once the
55
+ // escapes are gone the residue is plain text that scrolls -- which the
56
+ // terminal's scrollback, a pager, or Ctrl-C already handles, and which
57
+ // cannot repaint, retitle, or hide anything.
58
+ //
59
+ // Takes `unknown` for the same reason sanitizeForTerminal does (ahood-cli#122).
60
+ export function sanitizeDocumentForTerminal(text) {
61
+ return String(text).replace(CONTROL_CHARACTERS_EXCEPT_LAYOUT, " ");
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
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",