@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,35 @@
1
+ /**
2
+ * The three model-facing skill tools (spec §8.1 / §12.3).
3
+ *
4
+ * skill — Level 2 load (built in core; bound to the service here)
5
+ * skill_install — install from an external source
6
+ * skill_create — author a new skill from what the model just worked out
7
+ *
8
+ * D14 — SAME-TURN VISIBILITY. Both mutating tools end their success message
9
+ * with the new skill's Level 1 catalog line. The system prompt is snapshotted
10
+ * when the run starts (D2), so a skill installed on turn N is invisible in
11
+ * `<available_skills>` until the NEXT `prompt()`. Without the inline line the
12
+ * model installs something and then truthfully reports it cannot see it —
13
+ * which reads to the user as a broken install.
14
+ */
15
+ import { type AgentTool } from '@aragon-agent/core';
16
+ import type { SkillService } from './service.js';
17
+ export declare function createSkillInstallTool(service: SkillService, installerVersion: string): AgentTool;
18
+ export declare function createSkillCreateTool(service: SkillService, installerVersion: string): AgentTool;
19
+ /**
20
+ * All four tools, in the order they appear in `/tools`.
21
+ *
22
+ * `skill_find` sits directly after `skill` so the two READ operations are
23
+ * adjacent and the two MUTATING ones follow — the listing itself then reads as
24
+ * the safety gradient it is.
25
+ *
26
+ * `update` is deliberately absent (D-A5): it is a maintenance action for the
27
+ * user, not a capability for the model.
28
+ */
29
+ export declare function createSkillTools(service: SkillService, installerVersion?: string,
30
+ /**
31
+ * Live tool names for the ceiling snapshot. Supplied by `AgentController`;
32
+ * omitted by the one-shot `aragon skills` paths, where there is no agent and
33
+ * therefore no policy line to render.
34
+ */
35
+ getRegisteredTools?: () => readonly string[]): AgentTool[];
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The three model-facing skill tools (spec §8.1 / §12.3).
3
+ *
4
+ * skill — Level 2 load (built in core; bound to the service here)
5
+ * skill_install — install from an external source
6
+ * skill_create — author a new skill from what the model just worked out
7
+ *
8
+ * D14 — SAME-TURN VISIBILITY. Both mutating tools end their success message
9
+ * with the new skill's Level 1 catalog line. The system prompt is snapshotted
10
+ * when the run starts (D2), so a skill installed on turn N is invisible in
11
+ * `<available_skills>` until the NEXT `prompt()`. Without the inline line the
12
+ * model installs something and then truthfully reports it cannot see it —
13
+ * which reads to the user as a broken install.
14
+ */
15
+ import { createSkillFindTool, createSkillTool, errorResult, textResult, } from '@aragon-agent/core';
16
+ import { createSkill, installSkill } from './installer.js';
17
+ const INSTALL_DESCRIPTION = 'Install a new skill from a local directory, a git repository, or an https URL (a single ' +
18
+ 'SKILL.md or a .zip bundle). Use this when the user points you at a skill to install, or when a ' +
19
+ 'task clearly needs a capability that no listed skill covers and the user has given you a source. ' +
20
+ 'Requires user approval. Never install from a source the user did not provide.';
21
+ const CREATE_DESCRIPTION = 'Author a NEW skill from scratch and save it to disk so it is available in this and future ' +
22
+ 'sessions. Use this after you have worked out a repeatable procedure worth reusing (the user ' +
23
+ 'asks to "remember how to do X", or you had to rediscover the same non-obvious steps twice). ' +
24
+ 'The description field must state both what the skill does and WHEN to use it.';
25
+ /** Shape the success message, ending with the D14 inline catalog entry. */
26
+ function successText(result, verb) {
27
+ const lines = [
28
+ `${verb} skill "${result.name}" (v${result.version ?? '0.0.0'}, ${result.scope} scope, ` +
29
+ `${result.fileCount} files) at ${result.dir}.`,
30
+ 'It is available immediately in this conversation:',
31
+ ];
32
+ if (result.catalogLine)
33
+ lines.push(result.catalogLine);
34
+ lines.push(`Load it with skill(name="${result.name}").`);
35
+ return lines.join('\n');
36
+ }
37
+ export function createSkillInstallTool(service, installerVersion) {
38
+ return {
39
+ name: 'skill_install',
40
+ label: 'Install Skill',
41
+ description: INSTALL_DESCRIPTION,
42
+ parameters: {
43
+ type: 'object',
44
+ properties: {
45
+ source: {
46
+ type: 'string',
47
+ description: 'Local dir/file path, git URL, github:owner/repo[/subdir][#ref], or an https URL to a SKILL.md or .zip.',
48
+ },
49
+ scope: {
50
+ type: 'string',
51
+ enum: ['user', 'project'],
52
+ description: 'Where to install. Default: user.',
53
+ },
54
+ name: { type: 'string', description: 'Override the installed skill name (kebab-case).' },
55
+ },
56
+ required: ['source'],
57
+ },
58
+ async execute(_id, rawParams) {
59
+ const params = rawParams;
60
+ const source = typeof params.source === 'string' ? params.source.trim() : '';
61
+ if (source.length === 0)
62
+ return errorResult('skill_install requires a "source" parameter.');
63
+ const result = await installSkill(service, source, {
64
+ ...(params.scope === 'project' || params.scope === 'user' ? { scope: params.scope } : {}),
65
+ ...(typeof params.name === 'string' && params.name.trim().length > 0
66
+ ? { name: params.name.trim() }
67
+ : {}),
68
+ initiator: 'agent',
69
+ cwd: service.getCwdForInstall(),
70
+ installer: installerVersion,
71
+ });
72
+ if (!result.ok)
73
+ return errorResult(result.error ?? 'Installation failed.');
74
+ return textResult(successText(result, 'Installed'));
75
+ },
76
+ };
77
+ }
78
+ export function createSkillCreateTool(service, installerVersion) {
79
+ return {
80
+ name: 'skill_create',
81
+ label: 'Create Skill',
82
+ description: CREATE_DESCRIPTION,
83
+ parameters: {
84
+ type: 'object',
85
+ properties: {
86
+ name: { type: 'string', description: 'kebab-case skill name.' },
87
+ description: {
88
+ type: 'string',
89
+ description: 'What it does AND when to use it. This is the only text the model sees before loading the skill.',
90
+ },
91
+ body: { type: 'string', description: 'Markdown body of SKILL.md (no frontmatter).' },
92
+ files: {
93
+ type: 'array',
94
+ description: 'Optional bundled files.',
95
+ items: {
96
+ type: 'object',
97
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
98
+ required: ['path', 'content'],
99
+ },
100
+ },
101
+ scope: { type: 'string', enum: ['user', 'project'] },
102
+ activation: { type: 'string', enum: ['auto', 'manual'] },
103
+ },
104
+ required: ['name', 'description', 'body'],
105
+ },
106
+ async execute(_id, rawParams) {
107
+ const p = rawParams;
108
+ if (typeof p.name !== 'string' || typeof p.description !== 'string' || typeof p.body !== 'string') {
109
+ return errorResult('skill_create requires "name", "description" and "body" strings.');
110
+ }
111
+ const files = Array.isArray(p.files)
112
+ ? p.files.filter((f) => !!f &&
113
+ typeof f.path === 'string' &&
114
+ typeof f.content === 'string')
115
+ : [];
116
+ const result = await createSkill(service, {
117
+ name: p.name.trim(),
118
+ description: p.description.trim(),
119
+ body: p.body,
120
+ files,
121
+ ...(p.scope === 'project' || p.scope === 'user' ? { scope: p.scope } : {}),
122
+ ...(p.activation === 'manual' || p.activation === 'auto' ? { activation: p.activation } : {}),
123
+ initiator: 'agent',
124
+ }, { cwd: service.getCwdForInstall(), installer: installerVersion });
125
+ if (!result.ok)
126
+ return errorResult(result.error ?? 'Skill creation failed.');
127
+ return textResult(successText(result, 'Created'));
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * All four tools, in the order they appear in `/tools`.
133
+ *
134
+ * `skill_find` sits directly after `skill` so the two READ operations are
135
+ * adjacent and the two MUTATING ones follow — the listing itself then reads as
136
+ * the safety gradient it is.
137
+ *
138
+ * `update` is deliberately absent (D-A5): it is a maintenance action for the
139
+ * user, not a capability for the model.
140
+ */
141
+ export function createSkillTools(service, installerVersion = '0.0.0',
142
+ /**
143
+ * Live tool names for the ceiling snapshot. Supplied by `AgentController`;
144
+ * omitted by the one-shot `aragon skills` paths, where there is no agent and
145
+ * therefore no policy line to render.
146
+ */
147
+ getRegisteredTools) {
148
+ return [
149
+ createSkillTool({
150
+ registry: service.getRegistry(),
151
+ loadBody: (name) => service.loadBody(name),
152
+ bodyMaxBytes: service.getConfig().bodyMaxBytes,
153
+ platform: service.platform(),
154
+ // Called by the tool AFTER `enterFrame()`, so the snapshot includes the
155
+ // skill being loaded — see the numbered sequence in `skill-tool.ts`.
156
+ ...(getRegisteredTools
157
+ ? { policySnapshot: () => service.toolPolicyView(getRegisteredTools) }
158
+ : {}),
159
+ }),
160
+ createSkillFindTool({ registry: service.getRegistry() }),
161
+ createSkillInstallTool(service, installerVersion),
162
+ createSkillCreateTool(service, installerVersion),
163
+ ];
164
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `skills update` (spec §6) — re-fetch an installed skill from its recorded
3
+ * source.
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════
6
+ * THE STEP ORDER IS THE DESIGN. Do not reorder it for readability.
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ *
9
+ * Every cheap, local, refusable check runs BEFORE anything that touches the
10
+ * network or the disk (§6.2). Two of those orderings are load-bearing:
11
+ *
12
+ * · LOCAL MODIFICATIONS (step 5) are detected before the download, so a user
13
+ * whose edits would be discarded is told so without having paid for a clone
14
+ * first — and the refusal ends with "nothing was downloaded", which is what
15
+ * makes retrying obviously safe.
16
+ *
17
+ * · THE HOST ALLOWLIST IS RE-CHECKED (step 6, D-A7). The URL in the manifest
18
+ * passed policy at INSTALL time. If the user has since narrowed
19
+ * `skills.allowedHosts`, honouring the stored URL would make that setting
20
+ * silently inert on the one path that re-downloads code. Iteration 1 shipped
21
+ * the same class of bug via the `github:` shorthand; this is the same
22
+ * mistake wearing a different hat.
23
+ *
24
+ * NOT AVAILABLE TO THE MODEL (D-A5). Installing is "the user pointed at a source
25
+ * and I did the work"; updating is "the thing that was working is now different
26
+ * code". Its failure mode is regression, not refusal, and an agent that can swap
27
+ * out its own behavioural rules mid-task while nobody is watching is not a
28
+ * capability anyone asked for.
29
+ */
30
+ import { type SkillManifestSource } from '@aragon-agent/core/skills';
31
+ import { type FetchDeps, type SourceSpec } from './fetch-source.js';
32
+ import { type Initiator } from './installer.js';
33
+ import type { SkillService } from './service.js';
34
+ export type UpdateReason = 'not_found' | 'immutable_scope' | 'no_manifest' | 'no_upstream' | 'source_unavailable' | 'host_not_allowed' | 'locally_modified' | 'cancelled' | 'locked' | 'validation_failed' | 'io_error';
35
+ export interface UpdateResult {
36
+ ok: boolean;
37
+ name: string;
38
+ changed: boolean;
39
+ fromVersion?: string;
40
+ toVersion?: string;
41
+ reason?: UpdateReason;
42
+ error?: string;
43
+ /** Under `--dry-run`: the paths that would be added, removed or rewritten. */
44
+ changedFiles?: string[];
45
+ }
46
+ export interface UpdateOptions {
47
+ cwd: string;
48
+ installer: string;
49
+ dryRun?: boolean;
50
+ force?: boolean;
51
+ initiator?: Initiator;
52
+ /** Injected in tests to assert that a refusal reached NO network at all. */
53
+ fetchDeps?: Partial<FetchDeps>;
54
+ }
55
+ export declare function isHardFailure(result: UpdateResult): boolean;
56
+ /**
57
+ * Rebuild an install spec from what the manifest recorded, re-applying today's
58
+ * host policy (D-A7).
59
+ *
60
+ * Pure — spawns nothing, opens no socket. That is what lets `AC-A6` assert a
61
+ * zero-call `runProcess` on the refusal path rather than merely asserting that
62
+ * an error came back.
63
+ */
64
+ export declare function specFromManifestSource(src: SkillManifestSource, allowedHosts: string[]): {
65
+ ok: true;
66
+ spec: SourceSpec;
67
+ } | {
68
+ ok: false;
69
+ reason: UpdateReason;
70
+ error: string;
71
+ };
72
+ export declare function updateSkill(service: SkillService, name: string, opts: UpdateOptions): Promise<UpdateResult>;
73
+ /**
74
+ * Update every skill that has an upstream, serially.
75
+ *
76
+ * ONE FAILURE NEVER STOPS THE REST. In a set of thirty skills, a single dead
77
+ * repository is an ordinary Tuesday; aborting the run there would leave the
78
+ * other twenty-nine stale for a reason the user did not choose.
79
+ *
80
+ * Skills that cannot have an upstream (bundled, hand-authored, `skill_create`
81
+ * output) are filtered out rather than reported as errors — they are not
82
+ * failures, they are simply not candidates.
83
+ */
84
+ export declare function updateAllSkills(service: SkillService, opts: UpdateOptions): Promise<UpdateResult[]>;
85
+ export interface UpdateCheckResult {
86
+ name: string;
87
+ status: 'up-to-date' | 'outdated' | 'unknown';
88
+ /** What the manifest recorded at install time. */
89
+ currentRef?: string;
90
+ remoteRef?: string;
91
+ detail?: string;
92
+ }
93
+ /**
94
+ * Ask whether an update exists, WITHOUT downloading one.
95
+ *
96
+ * `unknown` is a first-class, frequently-correct answer (§6.4): a manifest
97
+ * written before `resolvedRef` existed, an https zip with no version handle, or
98
+ * a repo git cannot reach all genuinely leave us unable to tell. Reporting
99
+ * "up to date" in those cases would be a guess presented as a fact.
100
+ */
101
+ export declare function checkSkillUpdate(service: SkillService, name: string, deps?: Partial<FetchDeps>): Promise<UpdateCheckResult>;
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `skills update` (spec §6) — re-fetch an installed skill from its recorded
3
+ * source.
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════
6
+ * THE STEP ORDER IS THE DESIGN. Do not reorder it for readability.
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ *
9
+ * Every cheap, local, refusable check runs BEFORE anything that touches the
10
+ * network or the disk (§6.2). Two of those orderings are load-bearing:
11
+ *
12
+ * · LOCAL MODIFICATIONS (step 5) are detected before the download, so a user
13
+ * whose edits would be discarded is told so without having paid for a clone
14
+ * first — and the refusal ends with "nothing was downloaded", which is what
15
+ * makes retrying obviously safe.
16
+ *
17
+ * · THE HOST ALLOWLIST IS RE-CHECKED (step 6, D-A7). The URL in the manifest
18
+ * passed policy at INSTALL time. If the user has since narrowed
19
+ * `skills.allowedHosts`, honouring the stored URL would make that setting
20
+ * silently inert on the one path that re-downloads code. Iteration 1 shipped
21
+ * the same class of bug via the `github:` shorthand; this is the same
22
+ * mistake wearing a different hat.
23
+ *
24
+ * NOT AVAILABLE TO THE MODEL (D-A5). Installing is "the user pointed at a source
25
+ * and I did the work"; updating is "the thing that was working is now different
26
+ * code". Its failure mode is regression, not refusal, and an agent that can swap
27
+ * out its own behavioural rules mid-task while nobody is watching is not a
28
+ * capability anyone asked for.
29
+ */
30
+ import { randomUUID } from 'node:crypto';
31
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
32
+ import { join, resolve } from 'node:path';
33
+ import { parseFrontmatter, validateStagedSkill, } from '@aragon-agent/core/skills';
34
+ import { checkUrlAllowed, fetchSource, gitLsRemote, } from './fetch-source.js';
35
+ import { absoluteFiles, approve, commitDirectory, listStagedFiles, } from './installer.js';
36
+ import { acquireRootLock } from './lock.js';
37
+ import { buildManifest, checkManifest, writeManifest } from './manifest.js';
38
+ import { getStagingDir } from './paths.js';
39
+ /** `skipped` outcomes are normal for `--all`; only these two are real failures. */
40
+ const HARD_FAILURES = new Set(['validation_failed', 'io_error']);
41
+ export function isHardFailure(result) {
42
+ return !result.ok && result.reason !== undefined && HARD_FAILURES.has(result.reason);
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Manifest source -> fetchable spec
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Rebuild an install spec from what the manifest recorded, re-applying today's
49
+ * host policy (D-A7).
50
+ *
51
+ * Pure — spawns nothing, opens no socket. That is what lets `AC-A6` assert a
52
+ * zero-call `runProcess` on the refusal path rather than merely asserting that
53
+ * an error came back.
54
+ */
55
+ export function specFromManifestSource(src, allowedHosts) {
56
+ switch (src.kind) {
57
+ case 'inline':
58
+ return {
59
+ ok: false,
60
+ reason: 'no_upstream',
61
+ error: 'this skill was authored locally and has no upstream to update from',
62
+ };
63
+ case 'local-dir':
64
+ if (!existsSync(src.url)) {
65
+ return { ok: false, reason: 'source_unavailable', error: `${src.url} no longer exists` };
66
+ }
67
+ return { ok: true, spec: { kind: 'local-dir', path: src.url } };
68
+ case 'local-file':
69
+ if (!existsSync(src.url)) {
70
+ return { ok: false, reason: 'source_unavailable', error: `${src.url} no longer exists` };
71
+ }
72
+ return { ok: true, spec: { kind: 'local-file', path: src.url } };
73
+ case 'git':
74
+ case 'https-md':
75
+ case 'https-zip': {
76
+ const checked = checkUrlAllowed(src.url, allowedHosts);
77
+ if (!checked.ok)
78
+ return { ok: false, reason: 'host_not_allowed', error: checked.error };
79
+ if (src.kind === 'git') {
80
+ return {
81
+ ok: true,
82
+ spec: { kind: 'git', url: src.url, ref: src.ref, subdir: src.subdir },
83
+ };
84
+ }
85
+ return { ok: true, spec: { kind: src.kind, url: src.url } };
86
+ }
87
+ }
88
+ }
89
+ /** Paths that differ between two manifest file lists (added, removed, rewritten). */
90
+ function diffFiles(before, after) {
91
+ const oldMap = new Map(before.map((f) => [f.path, f.sha256]));
92
+ const newMap = new Map(after.map((f) => [f.path, f.sha256]));
93
+ const changed = new Set();
94
+ for (const [path, sha] of newMap)
95
+ if (oldMap.get(path) !== sha)
96
+ changed.add(path);
97
+ for (const path of oldMap.keys())
98
+ if (!newMap.has(path))
99
+ changed.add(path);
100
+ return [...changed].sort((a, b) => a.localeCompare(b, 'en'));
101
+ }
102
+ export async function updateSkill(service, name, opts) {
103
+ const fail = (reason, error) => ({
104
+ ok: false,
105
+ name,
106
+ changed: false,
107
+ reason,
108
+ error,
109
+ });
110
+ // 1-4 — pure, in-memory refusals. Nothing below this point should be reached
111
+ // by a skill that simply has no upstream.
112
+ const record = service.get(name);
113
+ if (!record)
114
+ return fail('not_found', `No skill named "${name}".`);
115
+ if (!record.writable) {
116
+ return fail('immutable_scope', `Cannot update a ${record.scope} skill.`);
117
+ }
118
+ const manifest = record.manifest;
119
+ if (!manifest) {
120
+ return fail('no_manifest', `"${name}" has no install manifest, so there is nothing to update it from. ` +
121
+ 'Overwriting it would delete files nobody recorded.');
122
+ }
123
+ if (manifest.source.kind === 'inline') {
124
+ return fail('no_upstream', `"${name}" was created locally and has no upstream.`);
125
+ }
126
+ // 5 — local modifications, BEFORE any download (§6.2).
127
+ //
128
+ // Computed even under `--force`, because step 10 needs it: "upstream matches
129
+ // the manifest" does NOT imply "the disk matches upstream" once the working
130
+ // copy has diverged, and restoring a diverged copy is exactly what someone
131
+ // typing `--force` is asking for.
132
+ const tamper = checkManifest(record.dir, manifest);
133
+ const locallyModified = [...tamper.modified, ...tamper.missing].sort((a, b) => a.localeCompare(b, 'en'));
134
+ if (!opts.force && locallyModified.length > 0) {
135
+ return fail('locally_modified', `${name} has local changes that update would discard: ${locallyModified.join(', ')}\n` +
136
+ 'Re-run with --force to overwrite, or copy your edits out first.\n' +
137
+ '(nothing was downloaded)');
138
+ }
139
+ // 6 — today's host policy, still before the network.
140
+ const config = service.getConfig();
141
+ const resolved = specFromManifestSource(manifest.source, config.allowedHosts);
142
+ if (!resolved.ok)
143
+ return fail(resolved.reason, resolved.error);
144
+ // 7 — the same fail-closed gate an install goes through (D17).
145
+ const decision = await approve(service, `Update skill "${name}" (v${manifest.version}) from ${manifest.source.url}.`, 'skills update');
146
+ if (!decision.ok)
147
+ return fail('cancelled', decision.error);
148
+ // 8 — the lock. Taken after approval so it is never held across a human
149
+ // decision, which would let LOCK_TTL_MS expire and the lock be preempted.
150
+ const root = resolve(record.dir, '..');
151
+ let lock;
152
+ try {
153
+ lock = acquireRootLock(root);
154
+ }
155
+ catch (err) {
156
+ return fail('locked', err instanceof Error ? err.message : String(err));
157
+ }
158
+ const stagingDir = join(getStagingDir(), randomUUID());
159
+ try {
160
+ // 9 — fetch and validate through EXACTLY the install path. A second,
161
+ // weaker validator here would be a hole that only opens on the update route.
162
+ const fetched = await fetchSource(resolved.spec, stagingDir, {
163
+ allowedHosts: config.allowedHosts,
164
+ userAgent: `aragon-agent-cli/${opts.installer}`,
165
+ ...(opts.fetchDeps ?? {}),
166
+ });
167
+ const contentDir = join(stagingDir, 'content');
168
+ if (fetched.dir !== contentDir) {
169
+ mkdirSync(contentDir, { recursive: true });
170
+ cpSync(fetched.dir, contentDir, { recursive: true, dereference: false, errorOnExist: false });
171
+ }
172
+ const staged = listStagedFiles(contentDir);
173
+ const entryPath = join(contentDir, 'SKILL.md');
174
+ const entryText = existsSync(entryPath) ? readFileSync(entryPath, 'utf-8') : null;
175
+ const validation = validateStagedSkill(entryText, staged, name, parseFrontmatter);
176
+ if (!validation.ok) {
177
+ const first = validation.issues.find((i) => i.level === 'error');
178
+ return fail('validation_failed', `Rejected: ${first?.code} - ${first?.message}`);
179
+ }
180
+ const frontmatter = validation.frontmatter;
181
+ // 10 — content comparison. Identical upstream ⇒ touch NOTHING: rewriting an
182
+ // unchanged directory would bump every mtime and make the next discovery
183
+ // pass look like a change that never happened.
184
+ const next = buildManifest({
185
+ dir: contentDir,
186
+ files: absoluteFiles(contentDir, staged),
187
+ name,
188
+ version: frontmatter.version,
189
+ installer: opts.installer,
190
+ source: fetched.source,
191
+ installedAt: manifest.installedAt,
192
+ });
193
+ // Under `--force`, a file the user edited will be rewritten even though
194
+ // upstream itself did not move, so it belongs in the "will change" set.
195
+ const changedFiles = [
196
+ ...new Set([...diffFiles(manifest.files, next.files), ...(opts.force ? locallyModified : [])]),
197
+ ].sort((a, b) => a.localeCompare(b, 'en'));
198
+ if (changedFiles.length === 0) {
199
+ return {
200
+ ok: true,
201
+ name,
202
+ changed: false,
203
+ fromVersion: manifest.version,
204
+ toVersion: frontmatter.version,
205
+ };
206
+ }
207
+ // 11 — dry run stops here, having written nothing outside staging.
208
+ if (opts.dryRun) {
209
+ return {
210
+ ok: true,
211
+ name,
212
+ changed: true,
213
+ fromVersion: manifest.version,
214
+ toVersion: frontmatter.version,
215
+ changedFiles,
216
+ };
217
+ }
218
+ // 12/13 — atomic replace plus the new manifest.
219
+ //
220
+ // NOT wrapped in `withRootLock`: step 8 already acquired the lock for this
221
+ // root, and `acquireRootLock` is NOT reentrant — a second acquire would sit
222
+ // out the full `LOCK_MAX_WAIT_MS` waiting for a lock this very call stack
223
+ // holds, then fail every update with a spurious "another aragon process is
224
+ // installing skills".
225
+ //
226
+ // `installedAt` is carried forward; `updatedAt` / `previousVersion` record
227
+ // this event.
228
+ commitDirectory(contentDir, record.dir);
229
+ writeManifest(record.dir, buildManifest({
230
+ dir: record.dir,
231
+ files: absoluteFiles(record.dir, listStagedFiles(record.dir)),
232
+ name,
233
+ version: frontmatter.version,
234
+ installer: opts.installer,
235
+ source: fetched.source,
236
+ installedAt: manifest.installedAt,
237
+ updatedAt: Date.now(),
238
+ previousVersion: manifest.version,
239
+ }));
240
+ // 15 — make the catalog and the slash commands reflect the new version now,
241
+ // not on the next launch.
242
+ //
243
+ // Reporting the outcome is the CALLER's job, exactly as it is for
244
+ // `installSkill()`. Announcing it from here as well printed the same line
245
+ // twice on both surfaces, and under `--all` it interleaved N per-skill
246
+ // notices through the summary table that exists to replace them.
247
+ service.reload();
248
+ return {
249
+ ok: true,
250
+ name,
251
+ changed: true,
252
+ fromVersion: manifest.version,
253
+ toVersion: frontmatter.version,
254
+ changedFiles,
255
+ };
256
+ }
257
+ catch (err) {
258
+ return fail('io_error', err instanceof Error ? err.message : String(err));
259
+ }
260
+ finally {
261
+ // 14 — always, in this order.
262
+ lock.release();
263
+ rmSync(stagingDir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // updateAllSkills
268
+ // ---------------------------------------------------------------------------
269
+ /**
270
+ * Update every skill that has an upstream, serially.
271
+ *
272
+ * ONE FAILURE NEVER STOPS THE REST. In a set of thirty skills, a single dead
273
+ * repository is an ordinary Tuesday; aborting the run there would leave the
274
+ * other twenty-nine stale for a reason the user did not choose.
275
+ *
276
+ * Skills that cannot have an upstream (bundled, hand-authored, `skill_create`
277
+ * output) are filtered out rather than reported as errors — they are not
278
+ * failures, they are simply not candidates.
279
+ */
280
+ export async function updateAllSkills(service, opts) {
281
+ const candidates = service
282
+ .list()
283
+ .filter((r) => r.writable && r.manifest !== null && r.manifest.source.kind !== 'inline');
284
+ const results = [];
285
+ for (const record of candidates) {
286
+ results.push(await updateSkill(service, record.name, opts));
287
+ }
288
+ return results;
289
+ }
290
+ /**
291
+ * Ask whether an update exists, WITHOUT downloading one.
292
+ *
293
+ * `unknown` is a first-class, frequently-correct answer (§6.4): a manifest
294
+ * written before `resolvedRef` existed, an https zip with no version handle, or
295
+ * a repo git cannot reach all genuinely leave us unable to tell. Reporting
296
+ * "up to date" in those cases would be a guess presented as a fact.
297
+ */
298
+ export async function checkSkillUpdate(service, name, deps = {}) {
299
+ const record = service.get(name);
300
+ if (!record)
301
+ return { name, status: 'unknown', detail: 'not installed' };
302
+ const manifest = record.manifest;
303
+ if (!manifest)
304
+ return { name, status: 'unknown', detail: 'no install manifest' };
305
+ if (manifest.source.kind !== 'git') {
306
+ return { name, status: 'unknown', detail: `${manifest.source.kind} sources cannot be checked` };
307
+ }
308
+ const pinned = manifest.source.resolvedRef ?? null;
309
+ if (!pinned) {
310
+ return { name, status: 'unknown', detail: 'installed before commit pinning was recorded' };
311
+ }
312
+ // Same allowlist re-validation as a real update — a `--check` is still an
313
+ // outbound request to whatever the manifest names.
314
+ const config = service.getConfig();
315
+ const allowed = checkUrlAllowed(manifest.source.url, config.allowedHosts);
316
+ if (!allowed.ok)
317
+ return { name, status: 'unknown', detail: allowed.error };
318
+ const remote = await gitLsRemote(manifest.source.url, manifest.source.ref, {
319
+ allowedHosts: config.allowedHosts,
320
+ ...deps,
321
+ });
322
+ if (!remote)
323
+ return { name, status: 'unknown', currentRef: pinned, detail: 'remote did not answer' };
324
+ return {
325
+ name,
326
+ status: remote === pinned ? 'up-to-date' : 'outdated',
327
+ currentRef: pinned,
328
+ remoteRef: remote,
329
+ };
330
+ }