@aragon-agent/cli 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,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
+ }