@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,502 @@
1
+ /**
2
+ * `aragon skills …` — the non-interactive surface (spec §7.3).
3
+ *
4
+ * Exit codes match the existing `runConfigSet` convention:
5
+ * 0 success · 1 run-time failure (network / fs / git) · 2 usage or validation.
6
+ *
7
+ * Nothing here renders Ink. These commands run in a one-shot process, so there
8
+ * is no App and therefore no human channel unless `--yes` says otherwise — the
9
+ * same fail-closed rule the agent tools follow, reached through the same gate.
10
+ */
11
+ import process from 'node:process';
12
+ import { createNodeSkillHost } from './node-host.js';
13
+ import { SkillService } from './service.js';
14
+ import { createSkill, installSkill, removeSkill, writableRootFor } from './installer.js';
15
+ import { checkManifest } from './manifest.js';
16
+ import { getUserSkillsDir } from './paths.js';
17
+ import { flushUsage, getUsagePath, listUsage, resetUsage } from './usage.js';
18
+ import { currentPlatform } from './node-host.js';
19
+ import { HOST_TOOL_NAMES } from '../tools/index.js';
20
+ import { checkSkillUpdate, isHardFailure, updateAllSkills, updateSkill, } from './updater.js';
21
+ import { loadConfig } from '../config/load.js';
22
+ import { updatePersistedConfig } from '../config/store.js';
23
+ import { rankCatalogRecords } from '@aragon-agent/core';
24
+ import { classifyBundledFile, resolveDeclaredTool } from '@aragon-agent/core/skills';
25
+ const EXIT_OK = 0;
26
+ const EXIT_RUNTIME = 1;
27
+ const EXIT_USAGE = 2;
28
+ /**
29
+ * `--yes` is the ONLY way a one-shot process can approve.
30
+ *
31
+ * There is no TTY prompt implementation here on purpose: adding one would mean
32
+ * a half-interactive command that hangs in CI. Without `--yes`, anything
33
+ * requiring approval fails with exit 2 and says which flag to add.
34
+ */
35
+ function makeCliApproval(yes) {
36
+ return { canPrompt: () => yes, request: async () => yes };
37
+ }
38
+ function buildService(flags, yes) {
39
+ const config = loadConfig(flags);
40
+ const service = new SkillService({
41
+ host: createNodeSkillHost(),
42
+ getCwd: () => config.cwd,
43
+ config: config.skills,
44
+ runtime: { forcedSkills: [], approveAll: yes },
45
+ approval: makeCliApproval(yes),
46
+ notify: (level, text) => process.stderr.write(`[skills] ${level}: ${text}\n`),
47
+ persist: (patch) => {
48
+ updatePersistedConfig({ skills: patch });
49
+ },
50
+ });
51
+ service.discover();
52
+ return { service, cwd: config.cwd };
53
+ }
54
+ function toJson(record) {
55
+ return {
56
+ name: record.name,
57
+ version: record.frontmatter.version,
58
+ scope: record.scope,
59
+ description: record.description,
60
+ dir: record.dir,
61
+ disabled: record.disabled,
62
+ invalid: record.invalid,
63
+ activation: record.frontmatter.activation,
64
+ writable: record.writable,
65
+ shadowed: record.shadowed,
66
+ source: record.manifest?.source ?? null,
67
+ integrity: record.integrity,
68
+ updatedAt: record.manifest?.updatedAt ?? null,
69
+ previousVersion: record.manifest?.previousVersion ?? null,
70
+ };
71
+ }
72
+ /**
73
+ * `--sort=recent` reuses the CATALOG ranking rather than sorting by timestamp.
74
+ *
75
+ * The point of the flag is "show me the order the model sees", so a second,
76
+ * subtly different notion of "recent" would answer a question nobody asked and
77
+ * quietly disagree with the thing it is supposed to explain.
78
+ */
79
+ function sortForListing(records, service, sort) {
80
+ if (sort !== 'recent')
81
+ return records;
82
+ const ranked = rankCatalogRecords(records, { usage: service.getUsage() });
83
+ const seen = new Set(ranked.map((r) => r.name));
84
+ // Ranking drops disabled / invalid / manual entries; keep them visible at the
85
+ // bottom, because `/skills list` is also how a user finds out WHY something
86
+ // is not showing up.
87
+ return [...ranked, ...records.filter((r) => !seen.has(r.name))];
88
+ }
89
+ /** Dispatch an `aragon skills <sub>` invocation and return the exit code. */
90
+ export async function runSkillsCommand(sub, argument, opts, flags, version) {
91
+ const yes = opts.yes === true;
92
+ if (sub === 'path') {
93
+ process.stdout.write(`${getUserSkillsDir()}\n`);
94
+ return EXIT_OK;
95
+ }
96
+ // Ahead of `buildService` like `path`: the counters live next to the skills
97
+ // directory, not inside it, so reading or deleting them needs no scan.
98
+ if (sub === 'usage')
99
+ return runUsage(opts);
100
+ const { service, cwd } = buildService(flags, yes);
101
+ switch (sub) {
102
+ case 'list': {
103
+ const records = sortForListing(service.list(), service, opts.sort);
104
+ if (opts.json) {
105
+ process.stdout.write(`${JSON.stringify(records.map(toJson), null, 2)}\n`);
106
+ return EXIT_OK;
107
+ }
108
+ if (records.length === 0) {
109
+ process.stdout.write('No skills installed.\n');
110
+ return EXIT_OK;
111
+ }
112
+ for (const record of records) {
113
+ const mark = record.invalid ? '!' : record.disabled ? 'x' : '-';
114
+ const modified = record.integrity === 'modified' ? ' !modified' : '';
115
+ process.stdout.write(`${mark} ${record.name.padEnd(24)} ${record.frontmatter.version.padEnd(8)} ` +
116
+ `${record.scope.padEnd(8)} ${record.description.replace(/\s+/g, ' ').slice(0, 60)}` +
117
+ `${modified}\n`);
118
+ }
119
+ for (const dir of service.untrustedDirs()) {
120
+ process.stderr.write(`[skills] untrusted project dir skipped: ${dir}\n`);
121
+ }
122
+ return EXIT_OK;
123
+ }
124
+ case 'info': {
125
+ if (!argument) {
126
+ process.stderr.write('Usage: aragon skills info <name>\n');
127
+ return EXIT_USAGE;
128
+ }
129
+ const record = service.get(argument);
130
+ if (!record) {
131
+ process.stderr.write(`No skill named "${argument}".\n`);
132
+ return EXIT_USAGE;
133
+ }
134
+ if (opts.json) {
135
+ process.stdout.write(`${JSON.stringify(toJson(record), null, 2)}\n`);
136
+ return EXIT_OK;
137
+ }
138
+ process.stdout.write([
139
+ `${record.name} v${record.frontmatter.version} (${record.scope})`,
140
+ record.description,
141
+ `Path: ${record.dir}`,
142
+ `Activation: ${record.frontmatter.activation}`,
143
+ record.manifest
144
+ ? `Source: ${record.manifest.source.kind} ${record.manifest.source.url}` +
145
+ (record.manifest.source.ref ? ` @ ${record.manifest.source.ref}` : '')
146
+ : 'Source: hand-authored (no install manifest)',
147
+ ...(record.manifest?.updatedAt
148
+ ? [
149
+ `Updated: ${new Date(record.manifest.updatedAt).toISOString()}` +
150
+ (record.manifest.previousVersion ? ` (from v${record.manifest.previousVersion})` : ''),
151
+ ]
152
+ : []),
153
+ ...(record.integrity === 'modified' ? ['Integrity: modified since install'] : []),
154
+ '',
155
+ ].join('\n'));
156
+ return EXIT_OK;
157
+ }
158
+ case 'install': {
159
+ if (!argument) {
160
+ process.stderr.write('Usage: aragon skills install <source> [--scope user|project] [--yes]\n');
161
+ return EXIT_USAGE;
162
+ }
163
+ const scope = parseScope(opts.scope);
164
+ if (scope === undefined && opts.scope) {
165
+ process.stderr.write(`Unknown scope "${opts.scope}". Use user or project.\n`);
166
+ return EXIT_USAGE;
167
+ }
168
+ const result = await installSkill(service, argument, {
169
+ ...(scope ? { scope } : {}),
170
+ ...(opts.name ? { name: opts.name } : {}),
171
+ initiator: 'user',
172
+ cwd,
173
+ installer: version,
174
+ });
175
+ if (!result.ok) {
176
+ process.stderr.write(`${result.error}\n`);
177
+ return isApprovalError(result.error) ? EXIT_USAGE : EXIT_RUNTIME;
178
+ }
179
+ process.stdout.write(`Installed ${result.name} v${result.version} (${result.fileCount} files) at ${result.dir}\n`);
180
+ return EXIT_OK;
181
+ }
182
+ case 'update':
183
+ return runUpdate(service, argument, opts, cwd, version);
184
+ case 'remove': {
185
+ if (!argument) {
186
+ process.stderr.write('Usage: aragon skills remove <name> [--yes]\n');
187
+ return EXIT_USAGE;
188
+ }
189
+ const result = await removeSkill(service, argument);
190
+ if (!result.ok) {
191
+ process.stderr.write(`${result.error}\n`);
192
+ return result.reason === 'io_error' ? EXIT_RUNTIME : EXIT_USAGE;
193
+ }
194
+ process.stdout.write(`Removed ${argument}\n`);
195
+ return EXIT_OK;
196
+ }
197
+ case 'create': {
198
+ if (!argument || !opts.description) {
199
+ process.stderr.write('Usage: aragon skills create <name> --description <d> [--scope user|project] [--yes]\n');
200
+ return EXIT_USAGE;
201
+ }
202
+ const scope = parseScope(opts.scope) ?? 'user';
203
+ if (!writableRootFor(scope, cwd)) {
204
+ process.stderr.write(`Cannot create a skill in a ${scope} scope.\n`);
205
+ return EXIT_USAGE;
206
+ }
207
+ const result = await createSkill(service, {
208
+ name: argument,
209
+ description: opts.description,
210
+ // A scaffold, not a finished skill: the body is a TODO the author is
211
+ // expected to open and fill in. Inventing prose here would produce
212
+ // plausible-looking instructions nobody wrote.
213
+ body: [`# ${argument}`, '', '## When to use', '', 'TODO', '', '## Steps', '', '1. TODO'].join('\n'),
214
+ scope,
215
+ initiator: 'user',
216
+ }, { cwd, installer: version });
217
+ if (!result.ok) {
218
+ process.stderr.write(`${result.error}\n`);
219
+ return isApprovalError(result.error) ? EXIT_USAGE : EXIT_RUNTIME;
220
+ }
221
+ process.stdout.write(`Created ${result.name} at ${result.dir}\nEdit SKILL.md to fill it in.\n`);
222
+ return EXIT_OK;
223
+ }
224
+ case 'doctor': {
225
+ let problems = 0;
226
+ for (const record of service.list()) {
227
+ for (const issue of record.issues) {
228
+ if (issue.level === 'error')
229
+ problems += 1;
230
+ process.stdout.write(`${issue.level}: ${record.name}: ${issue.code} - ${issue.message}\n`);
231
+ }
232
+ for (const line of checkDeclaredTools(record))
233
+ process.stdout.write(line);
234
+ for (const line of checkScriptPlatform(record, bundledFiles(service, record))) {
235
+ process.stdout.write(line);
236
+ }
237
+ if (record.integrity === 'modified') {
238
+ process.stdout.write(`warn: ${record.name}: SKILL.md changed since install\n`);
239
+ }
240
+ if (record.manifest) {
241
+ const tamper = checkManifest(record.dir, record.manifest);
242
+ for (const file of tamper.missing) {
243
+ process.stdout.write(`warn: ${record.name}: missing file ${file}\n`);
244
+ }
245
+ for (const file of tamper.modified) {
246
+ process.stdout.write(`warn: ${record.name}: modified since install ${file}\n`);
247
+ }
248
+ }
249
+ }
250
+ for (const error of service.errors())
251
+ process.stdout.write(`warn: ${error}\n`);
252
+ process.stdout.write(problems === 0
253
+ ? `Checked ${service.list().length} skills; no errors.\n`
254
+ : `Checked ${service.list().length} skills; ${problems} error(s).\n`);
255
+ return problems === 0 ? EXIT_OK : EXIT_USAGE;
256
+ }
257
+ case 'trust':
258
+ case 'untrust': {
259
+ if (!argument) {
260
+ process.stderr.write(`Usage: aragon skills ${sub} <dir>\n`);
261
+ return EXIT_USAGE;
262
+ }
263
+ const ok = sub === 'trust' ? service.trustDir(argument) : service.untrustDir(argument);
264
+ if (!ok) {
265
+ process.stderr.write(sub === 'trust' ? `Could not resolve "${argument}".\n` : `"${argument}" was not trusted.\n`);
266
+ return EXIT_USAGE;
267
+ }
268
+ process.stdout.write(`${sub === 'trust' ? 'Trusted' : 'Untrusted'} ${argument}\n`);
269
+ return EXIT_OK;
270
+ }
271
+ default:
272
+ process.stderr.write(`Unknown subcommand "${sub}". Try: list, info, install, update, remove, create, path, ` +
273
+ 'doctor, usage, trust, untrust.\n');
274
+ return EXIT_USAGE;
275
+ }
276
+ }
277
+ // ---------------------------------------------------------------------------
278
+ // doctor — the two checks added by this iteration (§5.7 / §8.4)
279
+ // ---------------------------------------------------------------------------
280
+ /**
281
+ * Can this skill's `allowed-tools` ever take effect?
282
+ *
283
+ * Resolved against `HOST_TOOL_NAMES`, not a live tool array: this process has no
284
+ * `AgentController` at all (P1-8), and the question doctor answers is "will this
285
+ * declaration work?", not "is it working at this instant". Using the superset
286
+ * also makes the output independent of how the CLI happened to be invoked, which
287
+ * is what lets it be snapshot-tested.
288
+ */
289
+ export function checkDeclaredTools(record) {
290
+ const declared = record.frontmatter.allowedTools;
291
+ if (declared.length === 0)
292
+ return [];
293
+ const resolved = [];
294
+ const absent = [];
295
+ const unknown = [];
296
+ for (const name of declared) {
297
+ const outcome = resolveDeclaredTool(name, HOST_TOOL_NAMES);
298
+ if (outcome === null)
299
+ unknown.push(name);
300
+ else if (outcome === 'absent')
301
+ absent.push(name);
302
+ else
303
+ resolved.push(outcome);
304
+ }
305
+ if (unknown.length > 0) {
306
+ return [
307
+ `warn: ${record.name}: tools: NOT ENFORCEABLE - unknown: ${unknown.join(', ')}. ` +
308
+ 'Fix the name or the ceiling will never apply.\n',
309
+ ];
310
+ }
311
+ // Flagged rather than silently accepted: the author wrote a permission intent
312
+ // that the system then ignores wholesale (D-G9), and nothing else says so.
313
+ if (record.frontmatter.activation === 'always') {
314
+ return [
315
+ `warn: ${record.name}: tools: "${record.name}" is activation: always, ` +
316
+ 'so its allowed-tools never applies.\n',
317
+ ];
318
+ }
319
+ const ignored = absent.length > 0 ? `; ignored on this host: ${absent.join(', ')}` : '';
320
+ return [`ok: ${record.name}: tools: ok (${resolved.join(', ')})${ignored}\n`];
321
+ }
322
+ /**
323
+ * Bundled files for a doctor check.
324
+ *
325
+ * `record.files` is `null` until a Level 2 load, and discovery deliberately does
326
+ * not hydrate it. `countUse: false` because inspecting a skill is not using one
327
+ * — counting it here would let a single `doctor` run reorder the catalog.
328
+ */
329
+ function bundledFiles(service, record) {
330
+ if (record.files)
331
+ return record.files;
332
+ try {
333
+ return service.loadBody(record.name, { countUse: false }).files;
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ }
339
+ /** Bundled scripts that cannot run on this host's shell (§8.4). Warn, never error. */
340
+ export function checkScriptPlatform(record, files) {
341
+ const scripts = files.filter((f) => classifyBundledFile(f.path) === 'script');
342
+ if (scripts.length === 0)
343
+ return [];
344
+ const platform = currentPlatform();
345
+ const unusable = platform === 'win32' ? '.sh' : '.ps1';
346
+ const runnable = scripts.filter((f) => !f.path.toLowerCase().endsWith(unusable));
347
+ if (runnable.length > 0)
348
+ return [];
349
+ const shell = platform === 'win32' ? 'cmd.exe (win32)' : 'a POSIX shell';
350
+ return [
351
+ `warn: ${record.name}: scripts: ${record.name} bundles only ${unusable} scripts; ` +
352
+ `this host runs ${shell}.\n`,
353
+ ];
354
+ }
355
+ // ---------------------------------------------------------------------------
356
+ // usage (§9 / FG7)
357
+ // ---------------------------------------------------------------------------
358
+ function runUsage(opts) {
359
+ if (opts.reset) {
360
+ // Same rule as every other mutating one-shot: `--yes` is the only approval
361
+ // channel here, because a half-interactive prompt would hang in CI.
362
+ if (!opts.yes) {
363
+ process.stderr.write('Refusing to delete usage counters without --yes. ' +
364
+ 'Run: aragon skills usage --reset --yes\n');
365
+ return EXIT_USAGE;
366
+ }
367
+ const removed = resetUsage();
368
+ process.stdout.write(`Cleared ${removed} usage entr${removed === 1 ? 'y' : 'ies'}.\n`);
369
+ return EXIT_OK;
370
+ }
371
+ const rows = listUsage();
372
+ if (opts.json) {
373
+ process.stdout.write(`${JSON.stringify({ path: getUsagePath(), skills: rows }, null, 2)}\n`);
374
+ return EXIT_OK;
375
+ }
376
+ if (rows.length === 0) {
377
+ process.stdout.write(`No skill usage recorded yet.\nFile: ${getUsagePath()}\n`);
378
+ return EXIT_OK;
379
+ }
380
+ const width = Math.max(...rows.map((r) => r.name.length), 5);
381
+ process.stdout.write(`${'skill'.padEnd(width)} uses last used\n`);
382
+ for (const row of rows) {
383
+ const stamp = row.lastUsedAt > 0 ? new Date(row.lastUsedAt).toISOString() : 'never';
384
+ process.stdout.write(`${row.name.padEnd(width)} ${String(row.useCount).padStart(4)} ${stamp}\n`);
385
+ }
386
+ process.stdout.write(`\nFile: ${getUsagePath()}\n` +
387
+ 'Stored locally, never transmitted. Clear it with "aragon skills usage --reset --yes", ' +
388
+ 'or switch it off with skills.usageTracking=false.\n');
389
+ return EXIT_OK;
390
+ }
391
+ // ---------------------------------------------------------------------------
392
+ // update (§6.5)
393
+ // ---------------------------------------------------------------------------
394
+ const UPDATE_USAGE = 'Usage: aragon skills update <name>|--all [--yes] [--force] [--dry-run]\n' +
395
+ ' aragon skills update --check [<name>|--all]\n';
396
+ /**
397
+ * One row of the `--all` summary, in the tense the user will read it in.
398
+ *
399
+ * The tense comes from the FLAG, not from the shape of the result. A successful
400
+ * real update also carries `changedFiles`, so inferring "dry run" from its
401
+ * presence made every genuine `--all` run report "would update" for work it had
402
+ * already committed to disk.
403
+ */
404
+ export function describeResult(result, dryRun) {
405
+ if (result.ok && !result.changed)
406
+ return 'up to date';
407
+ if (result.ok && result.changed) {
408
+ const versions = result.fromVersion && result.toVersion && result.fromVersion !== result.toVersion
409
+ ? ` ${result.fromVersion} -> ${result.toVersion}`
410
+ : '';
411
+ return `${dryRun ? 'would update' : 'updated'}${versions}`;
412
+ }
413
+ return `skipped: ${result.error?.split('\n')[0] ?? result.reason ?? 'unknown'}`;
414
+ }
415
+ async function runUpdate(service, argument, opts, cwd, version) {
416
+ if (opts.check)
417
+ return runUpdateCheck(service, argument, opts);
418
+ const all = opts.all === true || argument === '--all';
419
+ if (!all && !argument) {
420
+ process.stderr.write(UPDATE_USAGE);
421
+ return EXIT_USAGE;
422
+ }
423
+ const updateOpts = {
424
+ cwd,
425
+ installer: version,
426
+ ...(opts.dryRun ? { dryRun: true } : {}),
427
+ ...(opts.force ? { force: true } : {}),
428
+ initiator: 'user',
429
+ };
430
+ if (!all) {
431
+ const result = await updateSkill(service, argument, updateOpts);
432
+ flushUsage();
433
+ if (!result.ok) {
434
+ process.stderr.write(`${result.error}\n`);
435
+ return isHardFailure(result) ? EXIT_RUNTIME : EXIT_USAGE;
436
+ }
437
+ if (!result.changed) {
438
+ process.stdout.write(`${result.name} is up to date (v${result.fromVersion}).\n`);
439
+ return EXIT_OK;
440
+ }
441
+ if (opts.dryRun) {
442
+ process.stdout.write(`${result.name} would update ${result.fromVersion} -> ${result.toVersion}\n` +
443
+ (result.changedFiles ?? []).map((f) => ` ${f}\n`).join(''));
444
+ return EXIT_OK;
445
+ }
446
+ process.stdout.write(`Updated ${result.name} ${result.fromVersion} -> ${result.toVersion}\n`);
447
+ return EXIT_OK;
448
+ }
449
+ const results = await updateAllSkills(service, updateOpts);
450
+ flushUsage();
451
+ if (results.length === 0) {
452
+ process.stdout.write('No skills have an upstream to update from.\n');
453
+ return EXIT_OK;
454
+ }
455
+ const width = Math.max(...results.map((r) => r.name.length), 5);
456
+ process.stdout.write(`${'skill'.padEnd(width)} result\n`);
457
+ process.stdout.write(`${'-'.repeat(width)} ${'-'.repeat(27)}\n`);
458
+ for (const result of results) {
459
+ process.stdout.write(`${result.name.padEnd(width)} ${describeResult(result, opts.dryRun === true)}\n`);
460
+ }
461
+ const updated = results.filter((r) => r.ok && r.changed).length;
462
+ const unchanged = results.filter((r) => r.ok && !r.changed).length;
463
+ const failed = results.filter(isHardFailure).length;
464
+ const skipped = results.length - updated - unchanged - failed;
465
+ process.stdout.write(`Updated ${updated}, unchanged ${unchanged}, skipped ${skipped}, failed ${failed}.\n`);
466
+ // A skill that CANNOT be updated (hand-authored, bundled, locally modified) is
467
+ // an expected outcome of `--all`, not an error — exiting non-zero for it would
468
+ // make the command useless in any script.
469
+ return failed > 0 ? EXIT_RUNTIME : EXIT_OK;
470
+ }
471
+ async function runUpdateCheck(service, argument, opts) {
472
+ const all = opts.all === true || argument === '--all' || argument === undefined;
473
+ const names = all
474
+ ? service
475
+ .list()
476
+ .filter((r) => r.manifest?.source.kind === 'git')
477
+ .map((r) => r.name)
478
+ : [argument];
479
+ if (names.length === 0) {
480
+ process.stdout.write('No git-sourced skills to check.\n');
481
+ return EXIT_OK;
482
+ }
483
+ const width = Math.max(...names.map((n) => n.length), 5);
484
+ for (const name of names) {
485
+ const result = await checkSkillUpdate(service, name);
486
+ const detail = result.status === 'outdated'
487
+ ? ` (${(result.currentRef ?? '').slice(0, 8)} -> ${(result.remoteRef ?? '').slice(0, 8)})`
488
+ : result.detail
489
+ ? ` (${result.detail})`
490
+ : '';
491
+ process.stdout.write(`${name.padEnd(width)} ${result.status}${detail}\n`);
492
+ }
493
+ return EXIT_OK;
494
+ }
495
+ function parseScope(value) {
496
+ if (value === 'user' || value === 'project')
497
+ return value;
498
+ return undefined;
499
+ }
500
+ function isApprovalError(error) {
501
+ return Boolean(error && error.includes('requires approval'));
502
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Install-source identification and fetching (spec §8.2 / §9.2).
3
+ *
4
+ * ══════════════════════════════════════════════════════════════════════════
5
+ * `skill_install.source` IS A MODEL PARAMETER. Treat it as hostile input.
6
+ * ══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * Four rules are MUST (§8.2.1 / D20). Breaking any one of them is a shell or
9
+ * argv injection, not a style problem:
10
+ *
11
+ * 1. External processes are started with `execFile(cmd, argv[], {shell:false})`
12
+ * only. No `exec()`, no `execSync()`, no `shell: true`, no string
13
+ * concatenation of a command line. `tools/bash-tool.ts` DOES use
14
+ * `shell: true` — that is the bash tool's deliberate purpose and is NOT a
15
+ * precedent for this module. Copy `builtins.ts::copyToClipboard` instead.
16
+ *
17
+ * 2. Validate BEFORE building argv. `resolveSource()` is pure and spawns
18
+ * nothing; anything that fails here never reaches a subprocess.
19
+ *
20
+ * 3. Reject a leading `-` on every model-controlled fragment. This matters
21
+ * even with a pure argv array: `git clone --branch --upload-pack=calc.exe`
22
+ * makes git execute an arbitrary program. `shell: false` does not help.
23
+ * The whitelists below already force an alphanumeric first character; the
24
+ * explicit check is a second, separately-tested statement of intent.
25
+ *
26
+ * 4. Never let git prompt. A private repo would otherwise hang a headless
27
+ * install forever waiting on a credential prompt nobody can see.
28
+ */
29
+ import type { SkillManifestSource } from '@aragon-agent/core/skills';
30
+ export declare const HTTP_TIMEOUT_MS = 15000;
31
+ export declare const GIT_TIMEOUT_MS = 120000;
32
+ export declare const MAX_REDIRECTS = 3;
33
+ export declare const SKILL_MD_DOWNLOAD_MAX_BYTES: number;
34
+ /** `https:` only, hostname on the allowlist, never an IP literal or loopback. */
35
+ export declare function checkUrlAllowed(raw: string, allowedHosts: string[]): {
36
+ ok: true;
37
+ url: URL;
38
+ } | {
39
+ ok: false;
40
+ error: string;
41
+ };
42
+ export type SourceSpec = {
43
+ kind: 'local-dir';
44
+ path: string;
45
+ } | {
46
+ kind: 'local-file';
47
+ path: string;
48
+ } | {
49
+ kind: 'git';
50
+ url: string;
51
+ ref: string | null;
52
+ subdir: string | null;
53
+ } | {
54
+ kind: 'https-md';
55
+ url: string;
56
+ } | {
57
+ kind: 'https-zip';
58
+ url: string;
59
+ };
60
+ export type ResolveResult = {
61
+ ok: true;
62
+ spec: SourceSpec;
63
+ } | {
64
+ ok: false;
65
+ error: string;
66
+ };
67
+ /**
68
+ * Classify and validate an install source.
69
+ *
70
+ * Every rejection path returns before any subprocess or socket exists — that
71
+ * property is what AC-15 asserts (mocked `execFile`, call count 0).
72
+ */
73
+ export declare function resolveSource(source: string, allowedHosts: string[]): ResolveResult;
74
+ export interface RunProcessResult {
75
+ stdout: string;
76
+ }
77
+ export interface FetchDeps {
78
+ allowedHosts: string[];
79
+ /** Injectable so tests can assert the subprocess count without spawning git. */
80
+ runProcess?: (file: string, args: string[], options: {
81
+ cwd?: string;
82
+ }) => Promise<RunProcessResult>;
83
+ fetchImpl?: typeof fetch;
84
+ userAgent?: string;
85
+ }
86
+ export interface FetchOutcome {
87
+ /** Directory holding the fetched skill content. */
88
+ dir: string;
89
+ source: SkillManifestSource;
90
+ }
91
+ /** Fetch a resolved source into `stagingDir`, returning where the content landed. */
92
+ export declare function fetchSource(spec: SourceSpec, stagingDir: string, deps: FetchDeps): Promise<FetchOutcome>;
93
+ /**
94
+ * Ask a remote for the sha behind `ref`, without downloading anything.
95
+ *
96
+ * Subject to the SAME four rules as every other subprocess here (D20): argv
97
+ * array, `shell:false`, no leading `-` on any model-influenced fragment, and no
98
+ * credential prompt. The caller must have re-validated the URL against the host
99
+ * allowlist first — this function does not do policy.
100
+ *
101
+ * Returns `null` for "cannot tell", which is the honest answer for a source
102
+ * git cannot reach, an unmatched ref, or a non-sha reply.
103
+ */
104
+ export declare function gitLsRemote(url: string, ref: string | null, deps: FetchDeps): Promise<string | null>;