@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,223 @@
|
|
|
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
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
|
|
32
|
+
import { randomUUID } from 'node:crypto';
|
|
33
|
+
import { hostname } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import process from 'node:process';
|
|
36
|
+
/**
|
|
37
|
+
* A FILE, and dot-prefixed. `discover()` only considers `isDirectory` entries
|
|
38
|
+
* and already skips dotfiles, so the lock is invisible to the scanner twice
|
|
39
|
+
* over — asserted by `lock.test.ts` rather than left to reasoning (AC-A16).
|
|
40
|
+
*/
|
|
41
|
+
export const LOCK_FILENAME = '.aragon-skills.lock';
|
|
42
|
+
/** A lock older than this is presumed abandoned. */
|
|
43
|
+
export const LOCK_TTL_MS = 60_000;
|
|
44
|
+
export const LOCK_RETRY_MS = 250;
|
|
45
|
+
/** Total time spent waiting before giving up with a readable message. */
|
|
46
|
+
export const LOCK_MAX_WAIT_MS = 5_000;
|
|
47
|
+
/**
|
|
48
|
+
* Consecutive unreadable reads required before a lock is presumed abandoned.
|
|
49
|
+
*
|
|
50
|
+
* MUST BE > 1. `tryCreate()` creates the file and writes its payload as two
|
|
51
|
+
* separate syscalls, so a competing process can legitimately observe a
|
|
52
|
+
* zero-byte lock that is microseconds old. Treating that single observation as
|
|
53
|
+
* "corrupt, therefore abandoned" would delete a lock that is about to become
|
|
54
|
+
* valid and leave two processes both believing they own the root — precisely
|
|
55
|
+
* the race this module exists to close, and one that only appears under the
|
|
56
|
+
* contention the lock was written for. Re-reading a retry later costs 250 ms in
|
|
57
|
+
* the genuinely-corrupt case and removes the window in every other.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCK_UNREADABLE_CONFIRMATIONS = 2;
|
|
60
|
+
export class SkillLockError extends Error {
|
|
61
|
+
constructor(message) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'SkillLockError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Busy-wait. Deliberate: this runs in a CLI command, and there is nothing else to do. */
|
|
67
|
+
function sleepSync(ms) {
|
|
68
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Is `pid` still running?
|
|
72
|
+
*
|
|
73
|
+
* `ESRCH` means gone. `EPERM` means the process EXISTS but belongs to another
|
|
74
|
+
* user — treating that as dead would let one user steal another's lock, so it
|
|
75
|
+
* reports alive.
|
|
76
|
+
*/
|
|
77
|
+
function defaultIsProcessAlive(pid) {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return err.code !== 'ESRCH';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readLock(path) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
89
|
+
if (!parsed || typeof parsed !== 'object')
|
|
90
|
+
return null;
|
|
91
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.startedAt !== 'number')
|
|
92
|
+
return null;
|
|
93
|
+
if (typeof parsed.uuid !== 'string' || typeof parsed.host !== 'string')
|
|
94
|
+
return null;
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Write the lock file exclusively, or report that someone else holds it. */
|
|
102
|
+
function tryCreate(path, payload) {
|
|
103
|
+
let fd;
|
|
104
|
+
try {
|
|
105
|
+
// `wx` — atomic exclusive create. This single call is the entire
|
|
106
|
+
// correctness argument; everything else is recovery policy.
|
|
107
|
+
fd = openSync(path, 'wx');
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err.code === 'EEXIST')
|
|
111
|
+
return false;
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
writeSync(fd, JSON.stringify(payload));
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
// Closed IMMEDIATELY: on Windows an open handle blocks `unlink`, so holding
|
|
119
|
+
// it would make our own release fail and turn every lock into a 60-second
|
|
120
|
+
// stall for the next process.
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Take the install lock for `root`, blocking until it is free or the ceiling
|
|
127
|
+
* elapses.
|
|
128
|
+
*
|
|
129
|
+
* @throws {SkillLockError} when another live process holds it for longer than
|
|
130
|
+
* `LOCK_MAX_WAIT_MS`. The message names the pid and the time so the user can
|
|
131
|
+
* tell "my other terminal" from "a crashed process".
|
|
132
|
+
*/
|
|
133
|
+
export function acquireRootLock(root, opts = {}) {
|
|
134
|
+
const now = opts.now ?? Date.now;
|
|
135
|
+
const sleep = opts.sleep ?? sleepSync;
|
|
136
|
+
const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
137
|
+
mkdirSync(root, { recursive: true });
|
|
138
|
+
const path = join(root, LOCK_FILENAME);
|
|
139
|
+
const payload = {
|
|
140
|
+
pid: process.pid,
|
|
141
|
+
host: hostname(),
|
|
142
|
+
startedAt: now(),
|
|
143
|
+
uuid: randomUUID(),
|
|
144
|
+
};
|
|
145
|
+
const deadline = now() + LOCK_MAX_WAIT_MS;
|
|
146
|
+
let unreadableProbes = 0;
|
|
147
|
+
for (;;) {
|
|
148
|
+
if (tryCreate(path, payload))
|
|
149
|
+
return makeHandle(path, payload.uuid);
|
|
150
|
+
const holder = readLock(path);
|
|
151
|
+
let stale;
|
|
152
|
+
if (holder === null) {
|
|
153
|
+
// See LOCK_UNREADABLE_CONFIRMATIONS: one unreadable read is indistinguishable
|
|
154
|
+
// from a lock that is mid-creation, so it is not evidence of abandonment.
|
|
155
|
+
unreadableProbes += 1;
|
|
156
|
+
stale = unreadableProbes >= LOCK_UNREADABLE_CONFIRMATIONS;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
unreadableProbes = 0;
|
|
160
|
+
stale =
|
|
161
|
+
now() - holder.startedAt > LOCK_TTL_MS ||
|
|
162
|
+
(holder.host === payload.host && !isAlive(holder.pid));
|
|
163
|
+
}
|
|
164
|
+
let preempted = false;
|
|
165
|
+
if (stale) {
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(path);
|
|
168
|
+
preempted = true;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Either another process won the race to clean it up, or the file is
|
|
172
|
+
// pinned (a scanner holding a handle open on Windows). Fall through to
|
|
173
|
+
// the timed wait rather than retrying immediately: an unlink that keeps
|
|
174
|
+
// failing would otherwise spin here forever, and a CLI that never
|
|
175
|
+
// returns is a worse outcome than one that reports a busy lock.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Evaluated on EVERY iteration, including the preemption path, so that no
|
|
179
|
+
// branch of this loop can run unbounded.
|
|
180
|
+
if (now() >= deadline)
|
|
181
|
+
throw lockBusyError(holder);
|
|
182
|
+
// A successful preemption goes straight back to `tryCreate` rather than
|
|
183
|
+
// assuming ownership: two processes can judge the same lock stale at the
|
|
184
|
+
// same moment, and `wx` is the only thing entitled to decide between them.
|
|
185
|
+
if (!preempted)
|
|
186
|
+
sleep(LOCK_RETRY_MS);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** The give-up message. Names the holder when there is one to name. */
|
|
190
|
+
function lockBusyError(holder) {
|
|
191
|
+
const who = holder
|
|
192
|
+
? `pid ${holder.pid} since ${new Date(holder.startedAt).toISOString()}`
|
|
193
|
+
: 'an unreadable lock file that could not be cleared';
|
|
194
|
+
return new SkillLockError(`another aragon process is installing skills (lock held by ${who}); retry in a moment`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Release only OUR lock (D-A10).
|
|
198
|
+
*
|
|
199
|
+
* The uuid check is not defensive padding. If this process was judged stale and
|
|
200
|
+
* preempted, an unconditional `unlink` here would delete the NEW holder's lock
|
|
201
|
+
* and open a second concurrency window — the classic file-lock bug, and one
|
|
202
|
+
* that only shows up under exactly the contention the lock exists to handle.
|
|
203
|
+
*/
|
|
204
|
+
function makeHandle(path, uuid) {
|
|
205
|
+
let released = false;
|
|
206
|
+
return {
|
|
207
|
+
release() {
|
|
208
|
+
if (released)
|
|
209
|
+
return;
|
|
210
|
+
released = true;
|
|
211
|
+
try {
|
|
212
|
+
const holder = readLock(path);
|
|
213
|
+
if (holder?.uuid !== uuid)
|
|
214
|
+
return;
|
|
215
|
+
unlinkSync(path);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// A failed release costs at most one TTL of waiting for the next
|
|
219
|
+
// process. Letting it throw would end a SUCCESSFUL install in an error.
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.aragon-skill.json` — the install manifest (spec §10.2 / D15).
|
|
3
|
+
*
|
|
4
|
+
* Records provenance (where this skill came from) and a sha256 per file. That
|
|
5
|
+
* pays for three things at once: `/skills info` can answer "where did this come
|
|
6
|
+
* from", `skills doctor` can spot tampering, and an uninstall can remove
|
|
7
|
+
* exactly what was installed instead of nuking a directory the user may have
|
|
8
|
+
* added their own files to.
|
|
9
|
+
*/
|
|
10
|
+
import type { SkillManifest, SkillManifestSource } from '@aragon-agent/core/skills';
|
|
11
|
+
export declare const MANIFEST_FILENAME = ".aragon-skill.json";
|
|
12
|
+
export declare const MANIFEST_SCHEMA = 1;
|
|
13
|
+
export declare function sha256File(path: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Read a manifest, or `null` when it is absent, unreadable, or written by a
|
|
16
|
+
* newer schema. An unknown schema is deliberately NOT an error: the skill still
|
|
17
|
+
* works, we just cannot make provenance claims about it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readManifest(path: string): SkillManifest | null;
|
|
20
|
+
export declare function writeManifest(dir: string, manifest: SkillManifest): void;
|
|
21
|
+
/**
|
|
22
|
+
* Build a manifest by hashing every file that was just installed.
|
|
23
|
+
*
|
|
24
|
+
* `updatedAt` / `previousVersion` are written only by `skills update`, which
|
|
25
|
+
* also carries `installedAt` FORWARD from the old manifest: "when did I first
|
|
26
|
+
* install this" and "when was it last changed" answer different questions, and
|
|
27
|
+
* collapsing them loses the one the user actually asks during an incident.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildManifest(input: {
|
|
30
|
+
dir: string;
|
|
31
|
+
files: string[];
|
|
32
|
+
name: string;
|
|
33
|
+
version: string;
|
|
34
|
+
installer: string;
|
|
35
|
+
source: SkillManifestSource;
|
|
36
|
+
installedAt: number;
|
|
37
|
+
updatedAt?: number;
|
|
38
|
+
previousVersion?: string;
|
|
39
|
+
}): SkillManifest;
|
|
40
|
+
/** sha256 of an in-memory buffer — the same digest `sha256File` produces. */
|
|
41
|
+
export declare function sha256Buffer(buffer: Buffer): string;
|
|
42
|
+
export interface TamperReport {
|
|
43
|
+
missing: string[];
|
|
44
|
+
modified: string[];
|
|
45
|
+
}
|
|
46
|
+
/** Compare a manifest against what is actually on disk (`skills doctor`). */
|
|
47
|
+
export declare function checkManifest(dir: string, manifest: SkillManifest): TamperReport;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.aragon-skill.json` — the install manifest (spec §10.2 / D15).
|
|
3
|
+
*
|
|
4
|
+
* Records provenance (where this skill came from) and a sha256 per file. That
|
|
5
|
+
* pays for three things at once: `/skills info` can answer "where did this come
|
|
6
|
+
* from", `skills doctor` can spot tampering, and an uninstall can remove
|
|
7
|
+
* exactly what was installed instead of nuking a directory the user may have
|
|
8
|
+
* added their own files to.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
export const MANIFEST_FILENAME = '.aragon-skill.json';
|
|
14
|
+
export const MANIFEST_SCHEMA = 1;
|
|
15
|
+
export function sha256File(path) {
|
|
16
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read a manifest, or `null` when it is absent, unreadable, or written by a
|
|
20
|
+
* newer schema. An unknown schema is deliberately NOT an error: the skill still
|
|
21
|
+
* works, we just cannot make provenance claims about it.
|
|
22
|
+
*/
|
|
23
|
+
export function readManifest(path) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
26
|
+
if (!parsed || typeof parsed !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
if (parsed.schema !== MANIFEST_SCHEMA)
|
|
29
|
+
return null;
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function writeManifest(dir, manifest) {
|
|
37
|
+
writeFileSync(join(dir, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build a manifest by hashing every file that was just installed.
|
|
43
|
+
*
|
|
44
|
+
* `updatedAt` / `previousVersion` are written only by `skills update`, which
|
|
45
|
+
* also carries `installedAt` FORWARD from the old manifest: "when did I first
|
|
46
|
+
* install this" and "when was it last changed" answer different questions, and
|
|
47
|
+
* collapsing them loses the one the user actually asks during an incident.
|
|
48
|
+
*/
|
|
49
|
+
export function buildManifest(input) {
|
|
50
|
+
const files = [];
|
|
51
|
+
let totalBytes = 0;
|
|
52
|
+
for (const absolute of input.files) {
|
|
53
|
+
const rel = relative(input.dir, absolute).replace(/\\/g, '/');
|
|
54
|
+
if (rel === MANIFEST_FILENAME)
|
|
55
|
+
continue;
|
|
56
|
+
const bytes = statSync(absolute).size;
|
|
57
|
+
totalBytes += bytes;
|
|
58
|
+
files.push({ path: rel, bytes, sha256: sha256File(absolute) });
|
|
59
|
+
}
|
|
60
|
+
files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
61
|
+
return {
|
|
62
|
+
schema: MANIFEST_SCHEMA,
|
|
63
|
+
name: input.name,
|
|
64
|
+
version: input.version,
|
|
65
|
+
installedAt: input.installedAt,
|
|
66
|
+
installer: input.installer,
|
|
67
|
+
source: input.source,
|
|
68
|
+
files,
|
|
69
|
+
totalBytes,
|
|
70
|
+
// Written only when present, so a fresh install's manifest is byte-identical
|
|
71
|
+
// to what iteration 1 produced.
|
|
72
|
+
...(input.updatedAt !== undefined ? { updatedAt: input.updatedAt } : {}),
|
|
73
|
+
...(input.previousVersion !== undefined ? { previousVersion: input.previousVersion } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** sha256 of an in-memory buffer — the same digest `sha256File` produces. */
|
|
77
|
+
export function sha256Buffer(buffer) {
|
|
78
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
/** Compare a manifest against what is actually on disk (`skills doctor`). */
|
|
81
|
+
export function checkManifest(dir, manifest) {
|
|
82
|
+
const missing = [];
|
|
83
|
+
const modified = [];
|
|
84
|
+
for (const file of manifest.files) {
|
|
85
|
+
const absolute = join(dir, file.path);
|
|
86
|
+
try {
|
|
87
|
+
if (sha256File(absolute) !== file.sha256)
|
|
88
|
+
modified.push(file.path);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
missing.push(file.path);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { missing, modified };
|
|
95
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node implementation of core's `SkillHost` port (spec §11.3 / D1).
|
|
3
|
+
*
|
|
4
|
+
* This file is the ONLY reason `@aragon-agent/core` can stay free of `node:*`:
|
|
5
|
+
* every filesystem touch the skills engine needs arrives through here.
|
|
6
|
+
*
|
|
7
|
+
* ERROR CONTRACT (§10.1): these methods deliberately let fs exceptions escape.
|
|
8
|
+
* Swallowing ENOENT / EACCES here would turn "your skill directory is
|
|
9
|
+
* unreadable" into "your skill silently has no content"; the callers that must
|
|
10
|
+
* not throw (the `skill` tool's `execute()`) catch it themselves and produce a
|
|
11
|
+
* diagnosable message.
|
|
12
|
+
*/
|
|
13
|
+
import type { SkillHost, SkillPlatform } from '@aragon-agent/core';
|
|
14
|
+
/**
|
|
15
|
+
* Which shell family the `bash` tool will actually use here (D-G14).
|
|
16
|
+
*
|
|
17
|
+
* THE SINGLE PLACE `process.platform` is read for skills rendering. Core takes
|
|
18
|
+
* `platform` as a parameter instead of looking it up, which is what allows one
|
|
19
|
+
* machine's test run to cover both branches of the Level 3 guidance — a renderer
|
|
20
|
+
* that read `process` could only ever be snapshot-tested on the OS it ran on.
|
|
21
|
+
*/
|
|
22
|
+
export declare const currentPlatform: () => SkillPlatform;
|
|
23
|
+
export declare function createNodeSkillHost(): SkillHost;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node implementation of core's `SkillHost` port (spec §11.3 / D1).
|
|
3
|
+
*
|
|
4
|
+
* This file is the ONLY reason `@aragon-agent/core` can stay free of `node:*`:
|
|
5
|
+
* every filesystem touch the skills engine needs arrives through here.
|
|
6
|
+
*
|
|
7
|
+
* ERROR CONTRACT (§10.1): these methods deliberately let fs exceptions escape.
|
|
8
|
+
* Swallowing ENOENT / EACCES here would turn "your skill directory is
|
|
9
|
+
* unreadable" into "your skill silently has no content"; the callers that must
|
|
10
|
+
* not throw (the `skill` tool's `execute()`) catch it themselves and produce a
|
|
11
|
+
* diagnosable message.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
/**
|
|
17
|
+
* Which shell family the `bash` tool will actually use here (D-G14).
|
|
18
|
+
*
|
|
19
|
+
* THE SINGLE PLACE `process.platform` is read for skills rendering. Core takes
|
|
20
|
+
* `platform` as a parameter instead of looking it up, which is what allows one
|
|
21
|
+
* machine's test run to cover both branches of the Level 3 guidance — a renderer
|
|
22
|
+
* that read `process` could only ever be snapshot-tested on the OS it ran on.
|
|
23
|
+
*/
|
|
24
|
+
export const currentPlatform = () => process.platform === 'win32' ? 'win32' : 'posix';
|
|
25
|
+
export function createNodeSkillHost() {
|
|
26
|
+
return {
|
|
27
|
+
readTextFile(path) {
|
|
28
|
+
return readFileSync(path, 'utf-8');
|
|
29
|
+
},
|
|
30
|
+
listDir(path) {
|
|
31
|
+
return readdirSync(path, { withFileTypes: true }).map((entry) => {
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
if (entry.isFile()) {
|
|
34
|
+
try {
|
|
35
|
+
bytes = statSync(join(path, entry.name)).size;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A file that vanished between readdir and stat is reported as 0
|
|
39
|
+
// bytes rather than aborting the whole listing.
|
|
40
|
+
bytes = 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { name: entry.name, isDirectory: entry.isDirectory(), bytes };
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
exists(path) {
|
|
47
|
+
return existsSync(path);
|
|
48
|
+
},
|
|
49
|
+
join(...parts) {
|
|
50
|
+
return join(...parts);
|
|
51
|
+
},
|
|
52
|
+
relative(from, to) {
|
|
53
|
+
return relative(from, to);
|
|
54
|
+
},
|
|
55
|
+
isAbsolute(p) {
|
|
56
|
+
return isAbsolute(p);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill root resolution — the four scopes of spec §6.1, in ASCENDING precedence.
|
|
3
|
+
*
|
|
4
|
+
* bundled <pkg>/skills shipped with the npm package, read-only
|
|
5
|
+
* user <data>/skills where `aragon skills install` writes
|
|
6
|
+
* project <cwd>/.aragon/skills + <cwd>/.claude/skills (read-only)
|
|
7
|
+
* env ARAGON_SKILLS_PATH debugging / CI override, read-only
|
|
8
|
+
*
|
|
9
|
+
* Same layering the config resolver already uses (defaults › file › env ›
|
|
10
|
+
* flags), so there is no second mental model to learn.
|
|
11
|
+
*/
|
|
12
|
+
import type { SkillScope } from '@aragon-agent/core';
|
|
13
|
+
export interface SkillRoot {
|
|
14
|
+
dir: string;
|
|
15
|
+
scope: SkillScope;
|
|
16
|
+
/** False for bundled / env / `.claude/skills` — install and remove refuse these. */
|
|
17
|
+
writable: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** `<data>` — the per-user data root. Parent of `skills/`, and where sidecar state lives. */
|
|
20
|
+
export declare function getUserDataDir(): string;
|
|
21
|
+
/** `<data>/skills` — the default install target. */
|
|
22
|
+
export declare function getUserSkillsDir(): string;
|
|
23
|
+
/** `<data>/skills/.staging` — scratch space for in-flight installs (§8.3). */
|
|
24
|
+
export declare function getStagingDir(): string;
|
|
25
|
+
/** `<data>/skills/.staging/.trash` — where a replaced skill is parked (P1-6). */
|
|
26
|
+
export declare function getTrashDir(): string;
|
|
27
|
+
/**
|
|
28
|
+
* `<pkg>/skills` — the DATA directory of bundled skills.
|
|
29
|
+
*
|
|
30
|
+
* Not to be confused with `<pkg>/dist/skills`, which is the compiled output of
|
|
31
|
+
* `src/skills/*.ts`. This module lives at `src/skills/paths.ts` in dev and at
|
|
32
|
+
* `dist/skills/paths.js` after a build, so `'..', '..'` resolves to `<pkg>` in
|
|
33
|
+
* BOTH states — no `isPackaged`-style branch is needed or wanted.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getBundledSkillsDir(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Project roots for a working directory. `.claude/skills` is included for
|
|
38
|
+
* drop-in Claude Code interop (D8) and is marked read-only: `--scope project`
|
|
39
|
+
* always writes to `.aragon/skills`, never into another tool's directory.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveProjectSkillDirs(cwd: string, projectDirs?: string[]): SkillRoot[];
|
|
42
|
+
/** Roots listed in `ARAGON_SKILLS_PATH`, split on the platform delimiter. */
|
|
43
|
+
export declare function resolveEnvSkillDirs(value?: string | undefined): SkillRoot[];
|
|
44
|
+
/**
|
|
45
|
+
* All roots in ascending precedence. Non-existent directories are kept in the
|
|
46
|
+
* list — the scanner skips them — so callers can report "where would this go?"
|
|
47
|
+
* without a second resolution pass.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveSkillRoots(cwd: string, projectDirs?: string[]): SkillRoot[];
|
|
50
|
+
/**
|
|
51
|
+
* Canonical form of a directory for `trustedProjectDirs` comparison (P2-7).
|
|
52
|
+
*
|
|
53
|
+
* Without this, `C:\Repo\` and `c:\repo` are two different entries and the user
|
|
54
|
+
* gets asked to trust the same folder over and over — which trains them to
|
|
55
|
+
* click "yes" without reading, a strictly worse outcome than never asking.
|
|
56
|
+
* Returns `null` when the path cannot be resolved (deleted directory), so the
|
|
57
|
+
* caller skips it rather than persisting a stale entry.
|
|
58
|
+
*/
|
|
59
|
+
export declare function normalizeTrustPath(dir: string): string | null;
|
|
60
|
+
/** True when `dir` exists and is a directory (never throws). */
|
|
61
|
+
export declare function isDirectory(dir: string): boolean;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill root resolution — the four scopes of spec §6.1, in ASCENDING precedence.
|
|
3
|
+
*
|
|
4
|
+
* bundled <pkg>/skills shipped with the npm package, read-only
|
|
5
|
+
* user <data>/skills where `aragon skills install` writes
|
|
6
|
+
* project <cwd>/.aragon/skills + <cwd>/.claude/skills (read-only)
|
|
7
|
+
* env ARAGON_SKILLS_PATH debugging / CI override, read-only
|
|
8
|
+
*
|
|
9
|
+
* Same layering the config resolver already uses (defaults › file › env ›
|
|
10
|
+
* flags), so there is no second mental model to learn.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
13
|
+
import { delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
14
|
+
import process from 'node:process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { appPaths as paths } from '../config/app-paths.js';
|
|
17
|
+
/** `<data>` — the per-user data root. Parent of `skills/`, and where sidecar state lives. */
|
|
18
|
+
export function getUserDataDir() {
|
|
19
|
+
return paths.data;
|
|
20
|
+
}
|
|
21
|
+
/** `<data>/skills` — the default install target. */
|
|
22
|
+
export function getUserSkillsDir() {
|
|
23
|
+
return join(paths.data, 'skills');
|
|
24
|
+
}
|
|
25
|
+
/** `<data>/skills/.staging` — scratch space for in-flight installs (§8.3). */
|
|
26
|
+
export function getStagingDir() {
|
|
27
|
+
return join(getUserSkillsDir(), '.staging');
|
|
28
|
+
}
|
|
29
|
+
/** `<data>/skills/.staging/.trash` — where a replaced skill is parked (P1-6). */
|
|
30
|
+
export function getTrashDir() {
|
|
31
|
+
return join(getStagingDir(), '.trash');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `<pkg>/skills` — the DATA directory of bundled skills.
|
|
35
|
+
*
|
|
36
|
+
* Not to be confused with `<pkg>/dist/skills`, which is the compiled output of
|
|
37
|
+
* `src/skills/*.ts`. This module lives at `src/skills/paths.ts` in dev and at
|
|
38
|
+
* `dist/skills/paths.js` after a build, so `'..', '..'` resolves to `<pkg>` in
|
|
39
|
+
* BOTH states — no `isPackaged`-style branch is needed or wanted.
|
|
40
|
+
*/
|
|
41
|
+
export function getBundledSkillsDir() {
|
|
42
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Project roots for a working directory. `.claude/skills` is included for
|
|
46
|
+
* drop-in Claude Code interop (D8) and is marked read-only: `--scope project`
|
|
47
|
+
* always writes to `.aragon/skills`, never into another tool's directory.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveProjectSkillDirs(cwd, projectDirs = ['.aragon/skills', '.claude/skills']) {
|
|
50
|
+
return projectDirs.map((rel) => ({
|
|
51
|
+
dir: resolve(cwd, rel),
|
|
52
|
+
scope: 'project',
|
|
53
|
+
writable: rel.replace(/\\/g, '/').startsWith('.aragon/'),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/** Roots listed in `ARAGON_SKILLS_PATH`, split on the platform delimiter. */
|
|
57
|
+
export function resolveEnvSkillDirs(value = process.env.ARAGON_SKILLS_PATH) {
|
|
58
|
+
if (!value || value.trim().length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
return value
|
|
61
|
+
.split(delimiter)
|
|
62
|
+
.map((p) => p.trim())
|
|
63
|
+
.filter((p) => p.length > 0)
|
|
64
|
+
.map((p) => ({ dir: resolve(p), scope: 'env', writable: false }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* All roots in ascending precedence. Non-existent directories are kept in the
|
|
68
|
+
* list — the scanner skips them — so callers can report "where would this go?"
|
|
69
|
+
* without a second resolution pass.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveSkillRoots(cwd, projectDirs) {
|
|
72
|
+
return [
|
|
73
|
+
{ dir: getBundledSkillsDir(), scope: 'bundled', writable: false },
|
|
74
|
+
{ dir: getUserSkillsDir(), scope: 'user', writable: true },
|
|
75
|
+
...resolveProjectSkillDirs(cwd, projectDirs),
|
|
76
|
+
...resolveEnvSkillDirs(),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Canonical form of a directory for `trustedProjectDirs` comparison (P2-7).
|
|
81
|
+
*
|
|
82
|
+
* Without this, `C:\Repo\` and `c:\repo` are two different entries and the user
|
|
83
|
+
* gets asked to trust the same folder over and over — which trains them to
|
|
84
|
+
* click "yes" without reading, a strictly worse outcome than never asking.
|
|
85
|
+
* Returns `null` when the path cannot be resolved (deleted directory), so the
|
|
86
|
+
* caller skips it rather than persisting a stale entry.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeTrustPath(dir) {
|
|
89
|
+
try {
|
|
90
|
+
let real = realpathSync.native(resolve(dir));
|
|
91
|
+
while (real.length > 1 && real.endsWith(sep))
|
|
92
|
+
real = real.slice(0, -1);
|
|
93
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** True when `dir` exists and is a directory (never throws). */
|
|
100
|
+
export function isDirectory(dir) {
|
|
101
|
+
try {
|
|
102
|
+
return existsSync(dir) && statSync(dir).isDirectory();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|