@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.
- package/CHANGELOG.md +292 -0
- package/README.md +558 -0
- package/dist/agent/coalesce.d.ts +14 -0
- package/dist/agent/coalesce.js +41 -0
- package/dist/agent/controller.d.ts +132 -0
- package/dist/agent/controller.js +332 -0
- package/dist/agent/headless.d.ts +22 -0
- package/dist/agent/headless.js +74 -0
- package/dist/agent/reducer.d.ts +181 -0
- package/dist/agent/reducer.js +420 -0
- package/dist/agent/system-prompt.d.ts +30 -0
- package/dist/agent/system-prompt.js +58 -0
- package/dist/agent/usage.d.ts +13 -0
- package/dist/agent/usage.js +41 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +482 -0
- package/dist/commands/builtins.d.ts +16 -0
- package/dist/commands/builtins.js +223 -0
- package/dist/commands/registry.d.ts +48 -0
- package/dist/commands/registry.js +45 -0
- package/dist/commands/skills.d.ts +22 -0
- package/dist/commands/skills.js +625 -0
- package/dist/config/app-paths.d.ts +11 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/env.d.ts +28 -0
- package/dist/config/env.js +92 -0
- package/dist/config/load.d.ts +48 -0
- package/dist/config/load.js +158 -0
- package/dist/config/migrate-legacy-state.d.ts +78 -0
- package/dist/config/migrate-legacy-state.js +181 -0
- package/dist/config/schema.d.ts +249 -0
- package/dist/config/schema.js +247 -0
- package/dist/config/store.d.ts +44 -0
- package/dist/config/store.js +126 -0
- package/dist/input/keymap.d.ts +20 -0
- package/dist/input/keymap.js +85 -0
- package/dist/session/persist.d.ts +24 -0
- package/dist/session/persist.js +51 -0
- package/dist/skills/archive.d.ts +41 -0
- package/dist/skills/archive.js +152 -0
- package/dist/skills/cli-commands.d.ts +52 -0
- package/dist/skills/cli-commands.js +502 -0
- package/dist/skills/fetch-source.d.ts +104 -0
- package/dist/skills/fetch-source.js +448 -0
- package/dist/skills/installer.d.ts +117 -0
- package/dist/skills/installer.js +414 -0
- package/dist/skills/lock.d.ts +75 -0
- package/dist/skills/lock.js +223 -0
- package/dist/skills/manifest.d.ts +47 -0
- package/dist/skills/manifest.js +95 -0
- package/dist/skills/node-host.d.ts +23 -0
- package/dist/skills/node-host.js +59 -0
- package/dist/skills/paths.d.ts +61 -0
- package/dist/skills/paths.js +107 -0
- package/dist/skills/service.d.ts +256 -0
- package/dist/skills/service.js +730 -0
- package/dist/skills/tools.d.ts +35 -0
- package/dist/skills/tools.js +164 -0
- package/dist/skills/updater.d.ts +101 -0
- package/dist/skills/updater.js +330 -0
- package/dist/skills/usage.d.ts +72 -0
- package/dist/skills/usage.js +216 -0
- package/dist/tools/bash-tool.d.ts +12 -0
- package/dist/tools/bash-tool.js +131 -0
- package/dist/tools/diff.d.ts +15 -0
- package/dist/tools/diff.js +50 -0
- package/dist/tools/fs-tools.d.ts +16 -0
- package/dist/tools/fs-tools.js +215 -0
- package/dist/tools/index.d.ts +93 -0
- package/dist/tools/index.js +157 -0
- package/dist/tools/search-tools.d.ts +11 -0
- package/dist/tools/search-tools.js +225 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.js +522 -0
- package/dist/ui/AutocompletePopup.d.ts +21 -0
- package/dist/ui/AutocompletePopup.js +19 -0
- package/dist/ui/Composer.d.ts +40 -0
- package/dist/ui/Composer.js +61 -0
- package/dist/ui/Header.d.ts +30 -0
- package/dist/ui/Header.js +25 -0
- package/dist/ui/Logo.d.ts +50 -0
- package/dist/ui/Logo.js +85 -0
- package/dist/ui/Markdown.d.ts +23 -0
- package/dist/ui/Markdown.js +122 -0
- package/dist/ui/PromptInput.d.ts +81 -0
- package/dist/ui/PromptInput.js +383 -0
- package/dist/ui/SessionOpener.d.ts +30 -0
- package/dist/ui/SessionOpener.js +12 -0
- package/dist/ui/StatusBar.d.ts +39 -0
- package/dist/ui/StatusBar.js +43 -0
- package/dist/ui/ToastStack.d.ts +24 -0
- package/dist/ui/ToastStack.js +38 -0
- package/dist/ui/Transcript.d.ts +62 -0
- package/dist/ui/Transcript.js +128 -0
- package/dist/ui/capabilities.d.ts +25 -0
- package/dist/ui/capabilities.js +59 -0
- package/dist/ui/console-bridge.d.ts +19 -0
- package/dist/ui/console-bridge.js +52 -0
- package/dist/ui/density.d.ts +26 -0
- package/dist/ui/density.js +34 -0
- package/dist/ui/entries/AssistantEntry.d.ts +23 -0
- package/dist/ui/entries/AssistantEntry.js +13 -0
- package/dist/ui/entries/EntryFrame.d.ts +23 -0
- package/dist/ui/entries/EntryFrame.js +6 -0
- package/dist/ui/entries/ToolCard.d.ts +38 -0
- package/dist/ui/entries/ToolCard.js +72 -0
- package/dist/ui/entries/ToolPreview.d.ts +57 -0
- package/dist/ui/entries/ToolPreview.js +144 -0
- package/dist/ui/entries/UserEntry.d.ts +14 -0
- package/dist/ui/entries/UserEntry.js +5 -0
- package/dist/ui/exit-snapshot.d.ts +27 -0
- package/dist/ui/exit-snapshot.js +26 -0
- package/dist/ui/gauge.d.ts +24 -0
- package/dist/ui/gauge.js +36 -0
- package/dist/ui/glyphs.d.ts +87 -0
- package/dist/ui/glyphs.js +127 -0
- package/dist/ui/gradient.d.ts +11 -0
- package/dist/ui/gradient.js +50 -0
- package/dist/ui/layout/AppShell.d.ts +24 -0
- package/dist/ui/layout/AppShell.js +9 -0
- package/dist/ui/layout/Gutter.d.ts +67 -0
- package/dist/ui/layout/Gutter.js +42 -0
- package/dist/ui/layout/OverlayFrame.d.ts +62 -0
- package/dist/ui/layout/OverlayFrame.js +92 -0
- package/dist/ui/layout/ScrollViewport.d.ts +33 -0
- package/dist/ui/layout/ScrollViewport.js +71 -0
- package/dist/ui/layout/budget.d.ts +42 -0
- package/dist/ui/layout/budget.js +47 -0
- package/dist/ui/layout/frame.d.ts +58 -0
- package/dist/ui/layout/frame.js +75 -0
- package/dist/ui/layout/overlay-window.d.ts +43 -0
- package/dist/ui/layout/overlay-window.js +51 -0
- package/dist/ui/layout/scroll.d.ts +28 -0
- package/dist/ui/layout/scroll.js +76 -0
- package/dist/ui/layout/useTerminalSize.d.ts +13 -0
- package/dist/ui/layout/useTerminalSize.js +50 -0
- package/dist/ui/markdown-blocks.d.ts +62 -0
- package/dist/ui/markdown-blocks.js +120 -0
- package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
- package/dist/ui/overlays/ConfirmDialog.js +25 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
- package/dist/ui/overlays/HelpOverlay.js +60 -0
- package/dist/ui/overlays/ModelPicker.d.ts +29 -0
- package/dist/ui/overlays/ModelPicker.js +29 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
- package/dist/ui/overlays/SettingsScreen.js +106 -0
- package/dist/ui/palettes.d.ts +79 -0
- package/dist/ui/palettes.js +115 -0
- package/dist/ui/screen.d.ts +27 -0
- package/dist/ui/screen.js +57 -0
- package/dist/ui/theme.d.ts +78 -0
- package/dist/ui/theme.js +127 -0
- package/dist/ui/transcript-text.d.ts +27 -0
- package/dist/ui/transcript-text.js +110 -0
- package/package.json +73 -0
- 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
|
+
}
|