@aragon-agent/cli 0.4.3

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Install pipeline (spec §8.3): resolve → stage → validate → approve → commit.
3
+ *
4
+ * ══════════════════════════════════════════════════════════════════════════
5
+ * THE APPROVAL STEP IS FAIL-CLOSED. "No human channel" means REFUSE.
6
+ * ══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * `ApprovalGate.canPrompt()` is probed BEFORE `request()` is called, and a
9
+ * `false` answer aborts the install (§8.3.1 / D17). This is not decoration:
10
+ * `cli.tsx` hands mutating tools a `confirm` callback that resolves `true` when
11
+ * no handler is attached. That is a fine default for `--confirm`, which is
12
+ * opt-in and only reachable with the TUI mounted. For skills it would be a
13
+ * silent hole — `requireApproval` defaults to `true`, and under `-p` the App
14
+ * never mounts, so the handler is permanently null. Reusing that callback would
15
+ * make `aragon -p "install the skill at <url> and use it"` write a third-party
16
+ * directory into the user's skills root with nobody asked and nothing logged.
17
+ *
18
+ * Probing first is what makes the safe behaviour the DEFAULT rather than a rule
19
+ * someone has to remember.
20
+ */
21
+ import { randomUUID } from 'node:crypto';
22
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
23
+ import { join, relative, resolve } from 'node:path';
24
+ import { renderCatalogLine, validateStagedSkill, parseFrontmatter, } from '@aragon-agent/core/skills';
25
+ import { CREATE_MAX_FILE_BYTES, CREATE_MAX_FILES } from '@aragon-agent/core/skills';
26
+ import { checkStagedPath } from '@aragon-agent/core/skills';
27
+ import { fetchSource, resolveSource } from './fetch-source.js';
28
+ import { acquireRootLock } from './lock.js';
29
+ import { buildManifest, writeManifest, MANIFEST_FILENAME } from './manifest.js';
30
+ import { getStagingDir, getTrashDir, getUserSkillsDir, isDirectory, resolveProjectSkillDirs } from './paths.js';
31
+ const APPROVAL_UNAVAILABLE = 'Skill installation requires approval, but no interactive prompt is available. ' +
32
+ 'Re-run with --skills-yes, or set skills.requireApproval=false.';
33
+ /** Where a scope's writable root lives; `null` when the scope cannot be written. */
34
+ export function writableRootFor(scope, cwd) {
35
+ if (scope === 'user')
36
+ return getUserSkillsDir();
37
+ // `--scope project` ALWAYS means `.aragon/skills`. `.claude/skills` is read-only
38
+ // interop (D8) — writing there would pollute another tool's directory.
39
+ if (scope === 'project') {
40
+ return resolveProjectSkillDirs(cwd).find((r) => r.writable)?.dir ?? null;
41
+ }
42
+ return null;
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Approval (§8.3.1) — the ordered decision, in one place
46
+ // ---------------------------------------------------------------------------
47
+ export async function approve(service, summary, tool = 'skill_install') {
48
+ const config = service.getConfig();
49
+ const runtime = service.getRuntime();
50
+ // 1. Explicitly waived.
51
+ if (!config.requireApproval || runtime.approveAll)
52
+ return { ok: true };
53
+ const gate = service.getApproval();
54
+ // 2/3. Ask, but only after establishing that someone can answer.
55
+ if (gate.canPrompt()) {
56
+ const approved = await gate.request({ tool, summary });
57
+ return approved ? { ok: true } : { ok: false, error: 'Cancelled by user.' };
58
+ }
59
+ // 4. Nobody home ⇒ refuse. Never "assume yes".
60
+ return { ok: false, error: APPROVAL_UNAVAILABLE };
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Staging helpers
64
+ // ---------------------------------------------------------------------------
65
+ export function listStagedFiles(root, dir = root, depth = 0) {
66
+ if (depth > 8)
67
+ return [];
68
+ const out = [];
69
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
70
+ const absolute = join(dir, entry.name);
71
+ const rel = relative(root, absolute).replace(/\\/g, '/');
72
+ if (entry.isSymbolicLink()) {
73
+ out.push({ path: rel, bytes: 0, isSymlink: true });
74
+ continue;
75
+ }
76
+ if (entry.isDirectory()) {
77
+ if (entry.name === '.git')
78
+ continue;
79
+ out.push(...listStagedFiles(root, absolute, depth + 1));
80
+ continue;
81
+ }
82
+ if (!entry.isFile()) {
83
+ out.push({ path: rel, bytes: 0, isSymlink: true });
84
+ continue;
85
+ }
86
+ out.push({ path: rel, bytes: statSync(absolute).size });
87
+ }
88
+ return out;
89
+ }
90
+ export function absoluteFiles(root, files) {
91
+ return files.map((f) => join(root, f.path));
92
+ }
93
+ /**
94
+ * Replace `target` with `staged` as close to atomically as Windows allows.
95
+ *
96
+ * The displaced directory goes to `.staging/.trash/`, NOT to a sibling of the
97
+ * skills root. A `<root>/pdf-forms.bak-1785…` left behind by a crash mid-commit
98
+ * still contains a SKILL.md declaring `name: pdf-forms`, so the very next scan
99
+ * would find two skills with the same name in the same root and pick whichever
100
+ * `readdir` happened to return first. The user's experience of that is a skill
101
+ * that "sometimes" reverts to an old version. `.staging/.trash/` is dot-prefixed
102
+ * and therefore invisible to the scanner by construction.
103
+ */
104
+ export function commitDirectory(staged, target) {
105
+ mkdirSync(getTrashDir(), { recursive: true });
106
+ const backup = existsSync(target)
107
+ ? join(getTrashDir(), `${target.split(/[\\/]/).pop()}.bak-${Date.now()}`)
108
+ : null;
109
+ if (backup)
110
+ renameSync(target, backup);
111
+ try {
112
+ mkdirSync(resolve(target, '..'), { recursive: true });
113
+ try {
114
+ renameSync(staged, target);
115
+ }
116
+ catch (err) {
117
+ // Crossing a device boundary (staging on C:, skills root on D:) cannot be
118
+ // renamed; copy and drop the source instead.
119
+ if (err.code !== 'EXDEV')
120
+ throw err;
121
+ cpSync(staged, target, { recursive: true });
122
+ rmSync(staged, { recursive: true, force: true });
123
+ }
124
+ }
125
+ catch (err) {
126
+ if (backup) {
127
+ try {
128
+ rmSync(target, { recursive: true, force: true });
129
+ renameSync(backup, target);
130
+ }
131
+ catch {
132
+ // The rollback itself failed; the backup path is named in the error so
133
+ // the user can recover by hand rather than losing the skill silently.
134
+ throw new Error(`install failed and the previous version could not be restored automatically; ` +
135
+ `it is preserved at ${backup}. Original error: ${err.message}`);
136
+ }
137
+ }
138
+ throw err;
139
+ }
140
+ if (backup)
141
+ rmSync(backup, { recursive: true, force: true });
142
+ }
143
+ /**
144
+ * Run the directory-replacement critical section under the per-root lock (§7).
145
+ *
146
+ * WHY THE LOCK IS TAKEN *HERE* AND NOT AT THE TOP OF THE INSTALL (deviation
147
+ * from spec §7.3, recorded in spec §20):
148
+ *
149
+ * The corruption §7.1 describes lives entirely inside `commitDirectory()` plus
150
+ * the manifest write — staging directories are per-UUID and cannot collide.
151
+ * Taking the lock earlier would mean holding it across a network fetch AND
152
+ * across the human approval prompt. `LOCK_TTL_MS` is 60 s, and a user who takes
153
+ * longer than that to answer would have their lock judged stale and preempted —
154
+ * so the protection would silently switch itself off in exactly the interactive
155
+ * case it was written for, while a concurrent install would meanwhile fail with
156
+ * a lock error during a perfectly legitimate parallel download.
157
+ *
158
+ * Spec §6.2 already applies this reasoning to `update` (approval at step 7,
159
+ * lock at step 8). This is the same rule, applied to the one pipeline whose
160
+ * approval necessarily comes after the fetch.
161
+ */
162
+ export function withRootLock(root, critical) {
163
+ const lock = acquireRootLock(root);
164
+ try {
165
+ return critical();
166
+ }
167
+ finally {
168
+ lock.release();
169
+ }
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // install
173
+ // ---------------------------------------------------------------------------
174
+ export async function installSkill(service, source, opts) {
175
+ const config = service.getConfig();
176
+ // Pure validation first: a hostile source is rejected here, before any
177
+ // subprocess or socket exists (AC-15).
178
+ const resolved = resolveSource(source, config.allowedHosts);
179
+ if (!resolved.ok)
180
+ return { ok: false, error: resolved.error };
181
+ const scope = opts.scope ?? 'user';
182
+ const root = writableRootFor(scope, opts.cwd);
183
+ if (!root)
184
+ return { ok: false, error: `Cannot install into a ${scope} scope.` };
185
+ const stagingDir = join(getStagingDir(), randomUUID());
186
+ try {
187
+ const fetched = await fetchSource(resolved.spec, stagingDir, {
188
+ allowedHosts: config.allowedHosts,
189
+ userAgent: `aragon-agent-cli/${opts.installer}`,
190
+ });
191
+ // A local directory is used in place, so copy it into staging before doing
192
+ // anything that mutates — never move or symlink the user's own folder.
193
+ const contentDir = join(stagingDir, 'content');
194
+ if (fetched.dir !== contentDir) {
195
+ mkdirSync(contentDir, { recursive: true });
196
+ cpSync(fetched.dir, contentDir, { recursive: true, dereference: false, errorOnExist: false });
197
+ }
198
+ const staged = listStagedFiles(contentDir);
199
+ const entryPath = join(contentDir, 'SKILL.md');
200
+ const entryText = existsSync(entryPath) ? readFileSync(entryPath, 'utf-8') : null;
201
+ const dirName = opts.name ?? deriveName(entryText) ?? 'skill';
202
+ const validation = validateStagedSkill(entryText, staged, dirName, parseFrontmatter);
203
+ if (!validation.ok) {
204
+ const first = validation.issues.find((i) => i.level === 'error');
205
+ return { ok: false, error: `Rejected: ${first?.code} - ${first?.message}` };
206
+ }
207
+ const frontmatter = validation.frontmatter;
208
+ const finalName = opts.name ?? frontmatter.name;
209
+ if (opts.name && opts.name !== frontmatter.name) {
210
+ rewriteFrontmatterName(entryPath, opts.name);
211
+ }
212
+ const target = join(root, finalName);
213
+ const overwriting = isDirectory(target);
214
+ const decision = await approve(service, `${overwriting ? 'Replace' : 'Install'} skill "${finalName}" from ${source} into ${scope} scope ` +
215
+ `(${validation.fileCount} files, ${formatKb(validation.totalBytes)}).`);
216
+ if (!decision.ok)
217
+ return { ok: false, error: decision.error };
218
+ withRootLock(root, () => {
219
+ commitDirectory(contentDir, target);
220
+ writeManifest(target, buildManifest({
221
+ dir: target,
222
+ files: absoluteFiles(target, listStagedFiles(target)),
223
+ name: finalName,
224
+ version: frontmatter.version,
225
+ installer: opts.installer,
226
+ source: fetched.source,
227
+ installedAt: Date.now(),
228
+ }));
229
+ });
230
+ service.reload();
231
+ const record = service.get(finalName);
232
+ return {
233
+ ok: true,
234
+ name: finalName,
235
+ version: frontmatter.version,
236
+ scope,
237
+ dir: target,
238
+ fileCount: validation.fileCount,
239
+ totalBytes: validation.totalBytes,
240
+ ...(record ? { catalogLine: renderCatalogLine(record) } : {}),
241
+ };
242
+ }
243
+ catch (err) {
244
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
245
+ }
246
+ finally {
247
+ // Always: a staging leftover is both disk waste and, if it ever became
248
+ // visible to the scanner, a phantom skill.
249
+ rmSync(stagingDir, { recursive: true, force: true });
250
+ }
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // create (§8.4)
254
+ // ---------------------------------------------------------------------------
255
+ export async function createSkill(service, input, opts) {
256
+ const scope = input.scope ?? 'user';
257
+ const root = writableRootFor(scope, opts.cwd);
258
+ if (!root)
259
+ return { ok: false, error: `Cannot create a skill in a ${scope} scope.` };
260
+ const files = input.files ?? [];
261
+ if (files.length > CREATE_MAX_FILES) {
262
+ return { ok: false, error: `At most ${CREATE_MAX_FILES} bundled files are allowed.` };
263
+ }
264
+ for (const file of files) {
265
+ const issue = checkStagedPath(file.path);
266
+ if (issue)
267
+ return { ok: false, error: `${issue.code}: ${issue.message}` };
268
+ if (Buffer.byteLength(file.content, 'utf-8') > CREATE_MAX_FILE_BYTES) {
269
+ return { ok: false, error: `${file.path} exceeds ${CREATE_MAX_FILE_BYTES} bytes.` };
270
+ }
271
+ }
272
+ const stagingDir = join(getStagingDir(), randomUUID());
273
+ const contentDir = join(stagingDir, 'content');
274
+ try {
275
+ mkdirSync(contentDir, { recursive: true });
276
+ const entryPath = join(contentDir, 'SKILL.md');
277
+ writeFileUtf8(entryPath, renderSkillMarkdown(input));
278
+ for (const file of files) {
279
+ const absolute = join(contentDir, file.path);
280
+ mkdirSync(resolve(absolute, '..'), { recursive: true });
281
+ writeFileUtf8(absolute, file.content);
282
+ }
283
+ const staged = listStagedFiles(contentDir);
284
+ const validation = validateStagedSkill(readFileSync(entryPath, 'utf-8'), staged, input.name, parseFrontmatter);
285
+ if (!validation.ok) {
286
+ const first = validation.issues.find((i) => i.level === 'error');
287
+ return { ok: false, error: `Rejected: ${first?.code} - ${first?.message}` };
288
+ }
289
+ const target = join(root, input.name);
290
+ const existing = service.get(input.name);
291
+ const summary = isDirectory(target)
292
+ ? `Skill "${input.name}" already exists (v${existing?.frontmatter.version ?? '?'}). ` +
293
+ 'Overwrite with the new version?'
294
+ : `Create skill "${input.name}" in ${scope} scope (${validation.fileCount} files, ` +
295
+ `${formatKb(validation.totalBytes)}).`;
296
+ const decision = await approve(service, summary);
297
+ if (!decision.ok)
298
+ return { ok: false, error: decision.error };
299
+ withRootLock(root, () => {
300
+ commitDirectory(contentDir, target);
301
+ writeManifest(target, buildManifest({
302
+ dir: target,
303
+ files: absoluteFiles(target, listStagedFiles(target)),
304
+ name: input.name,
305
+ version: '0.1.0',
306
+ installer: opts.installer,
307
+ source: { kind: 'inline', url: 'skill_create', ref: null, subdir: null, resolvedRef: null },
308
+ installedAt: Date.now(),
309
+ }));
310
+ });
311
+ service.reload();
312
+ const record = service.get(input.name);
313
+ return {
314
+ ok: true,
315
+ name: input.name,
316
+ version: '0.1.0',
317
+ scope,
318
+ dir: target,
319
+ fileCount: validation.fileCount,
320
+ totalBytes: validation.totalBytes,
321
+ ...(record ? { catalogLine: renderCatalogLine(record) } : {}),
322
+ };
323
+ }
324
+ catch (err) {
325
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
326
+ }
327
+ finally {
328
+ rmSync(stagingDir, { recursive: true, force: true });
329
+ }
330
+ }
331
+ // ---------------------------------------------------------------------------
332
+ // remove (§7.2)
333
+ // ---------------------------------------------------------------------------
334
+ /**
335
+ * Refusal is a NORMAL outcome here, not an exception — bundled, env and
336
+ * `.claude/skills` skills are all legitimately un-removable — which is why this
337
+ * returns a result object rather than `Promise<void>`.
338
+ */
339
+ export async function removeSkill(service, name) {
340
+ const record = service.get(name);
341
+ if (!record)
342
+ return { ok: false, reason: 'not_found', error: `No skill named "${name}".` };
343
+ if (!record.writable) {
344
+ return {
345
+ ok: false,
346
+ reason: 'immutable_scope',
347
+ error: `Cannot remove a ${record.scope} skill.`,
348
+ };
349
+ }
350
+ const gate = service.getApproval();
351
+ if (!service.getRuntime().approveAll) {
352
+ if (!gate.canPrompt()) {
353
+ return { ok: false, reason: 'cancelled', error: APPROVAL_UNAVAILABLE };
354
+ }
355
+ const approved = await gate.request({
356
+ tool: 'skills remove',
357
+ summary: `Delete skill "${name}" from ${record.dir}?`,
358
+ });
359
+ if (!approved)
360
+ return { ok: false, reason: 'cancelled', error: 'Cancelled by user.' };
361
+ }
362
+ try {
363
+ // Under the same root lock as install: a delete racing a commit is the
364
+ // mirror image of §7.1 — the installer's `existsSync` probe and its rename
365
+ // would straddle the removal.
366
+ withRootLock(resolve(record.dir, '..'), () => {
367
+ rmSync(record.dir, { recursive: true, force: true });
368
+ });
369
+ }
370
+ catch (err) {
371
+ return { ok: false, reason: 'io_error', error: err.message };
372
+ }
373
+ service.reload();
374
+ return { ok: true };
375
+ }
376
+ // ---------------------------------------------------------------------------
377
+ // Small helpers
378
+ // ---------------------------------------------------------------------------
379
+ function writeFileUtf8(path, content) {
380
+ writeFileSync(path, content, 'utf-8');
381
+ }
382
+ function formatKb(bytes) {
383
+ return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`;
384
+ }
385
+ function deriveName(entryText) {
386
+ if (!entryText)
387
+ return null;
388
+ const parsed = parseFrontmatter(entryText);
389
+ const name = parsed?.data.name;
390
+ return typeof name === 'string' && name.length > 0 ? name : null;
391
+ }
392
+ /** The ONE content rewrite an install is allowed to make (`--name` override). */
393
+ function rewriteFrontmatterName(entryPath, name) {
394
+ const source = readFileSync(entryPath, 'utf-8');
395
+ const replaced = source.replace(/^(\s*name\s*:).*$/m, `$1 ${name}`);
396
+ writeFileUtf8(entryPath, replaced.includes(`: ${name}`) ? replaced : source);
397
+ }
398
+ /** Assemble a SKILL.md from `skill_create` parameters. */
399
+ export function renderSkillMarkdown(input) {
400
+ // Values go into YAML scalars, so a stray newline would forge new keys.
401
+ const oneLine = (v) => v.replace(/[\r\n]+/g, ' ').trim();
402
+ return [
403
+ '---',
404
+ `name: ${oneLine(input.name)}`,
405
+ `description: ${oneLine(input.description)}`,
406
+ 'version: 0.1.0',
407
+ `activation: ${input.activation ?? 'auto'}`,
408
+ '---',
409
+ '',
410
+ input.body.trimStart(),
411
+ '',
412
+ ].join('\n');
413
+ }
414
+ export { MANIFEST_FILENAME };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Per-root advisory install lock (spec §7).
3
+ *
4
+ * ══════════════════════════════════════════════════════════════════════════
5
+ * WHAT THIS PREVENTS: two aragon processes installing at once can DELETE a
6
+ * skill, and the path that does it reports success.
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ *
9
+ * `commitDirectory()` is `existsSync(target) → rename(target → .trash) →
10
+ * rename(staging → target)`. Interleave two of those (§7.1):
11
+ *
12
+ * A: existsSync = true → moves the old version to .trash
13
+ * B: existsSync = false → decides there is no backup to take
14
+ * A: rename(stagingA → target)
15
+ * B: rename(stagingB → target) ← overwrites A; A's backup is now orphaned
16
+ * A: rmSync(backup) ← deletes the user's previous version
17
+ *
18
+ * If B then fails, its rollback is a no-op because it believes `backup === null`
19
+ * — and the skill is gone. Nothing in that sequence throws.
20
+ *
21
+ * The lock is ADVISORY and PER WRITABLE ROOT (D-A9), not per skill: the danger
22
+ * is the shared `.staging/.trash` bookkeeping, which a per-skill lock would not
23
+ * cover. Serialising installs costs a few seconds of queueing on a command a
24
+ * human typed.
25
+ *
26
+ * KNOWN LIMIT (RA4): `wx` is atomic on local filesystems and on SMB, but not
27
+ * reliably on every NFS implementation. The TTL and the 5-second ceiling below
28
+ * mean the worst case there is the CURRENT behaviour (no mutual exclusion) — a
29
+ * deadlock is not reachable.
30
+ */
31
+ /**
32
+ * A FILE, and dot-prefixed. `discover()` only considers `isDirectory` entries
33
+ * and already skips dotfiles, so the lock is invisible to the scanner twice
34
+ * over — asserted by `lock.test.ts` rather than left to reasoning (AC-A16).
35
+ */
36
+ export declare const LOCK_FILENAME = ".aragon-skills.lock";
37
+ /** A lock older than this is presumed abandoned. */
38
+ export declare const LOCK_TTL_MS = 60000;
39
+ export declare const LOCK_RETRY_MS = 250;
40
+ /** Total time spent waiting before giving up with a readable message. */
41
+ export declare const LOCK_MAX_WAIT_MS = 5000;
42
+ /**
43
+ * Consecutive unreadable reads required before a lock is presumed abandoned.
44
+ *
45
+ * MUST BE > 1. `tryCreate()` creates the file and writes its payload as two
46
+ * separate syscalls, so a competing process can legitimately observe a
47
+ * zero-byte lock that is microseconds old. Treating that single observation as
48
+ * "corrupt, therefore abandoned" would delete a lock that is about to become
49
+ * valid and leave two processes both believing they own the root — precisely
50
+ * the race this module exists to close, and one that only appears under the
51
+ * contention the lock was written for. Re-reading a retry later costs 250 ms in
52
+ * the genuinely-corrupt case and removes the window in every other.
53
+ */
54
+ export declare const LOCK_UNREADABLE_CONFIRMATIONS = 2;
55
+ export declare class SkillLockError extends Error {
56
+ constructor(message: string);
57
+ }
58
+ export interface LockHandle {
59
+ release(): void;
60
+ }
61
+ export interface AcquireLockOptions {
62
+ now?: () => number;
63
+ sleep?: (ms: number) => void;
64
+ /** Injected for tests; defaults to `process.kill(pid, 0)` liveness probing. */
65
+ isProcessAlive?: (pid: number) => boolean;
66
+ }
67
+ /**
68
+ * Take the install lock for `root`, blocking until it is free or the ceiling
69
+ * elapses.
70
+ *
71
+ * @throws {SkillLockError} when another live process holds it for longer than
72
+ * `LOCK_MAX_WAIT_MS`. The message names the pid and the time so the user can
73
+ * tell "my other terminal" from "a crashed process".
74
+ */
75
+ export declare function acquireRootLock(root: string, opts?: AcquireLockOptions): LockHandle;