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