loopengine 0.1.22 → 0.1.27
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/README.md +10 -10
- package/dist/bin/{package-manager.d.ts → ability-manager.d.ts} +39 -33
- package/dist/bin/{package-manager.js → ability-manager.js} +223 -98
- package/dist/bin/cli.js +22 -22
- package/dist/web/agents-config-page.js +15 -6
- package/dist/web/env-admin.d.ts +29 -10
- package/dist/web/env-admin.js +39 -21
- package/dist/web/http-tool-admin.d.ts +23 -12
- package/dist/web/http-tool-admin.js +60 -30
- package/package.json +3 -3
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Backs `loopengine add-
|
|
2
|
-
// bin/cli.ts) — the install/upgrade/remove mechanics
|
|
3
|
-
// specifies for a "loopengine
|
|
1
|
+
// Backs `loopengine add-ability|upgrade-ability|remove-ability` (see
|
|
2
|
+
// bin/cli.ts) — the install/upgrade/remove mechanics ABILITIES.md
|
|
3
|
+
// specifies for a "loopengine ability": a bundle of tool files, skill
|
|
4
4
|
// directories, and actauth rules, installed by *copying* files into an
|
|
5
|
-
// agent's own tree (never an npm import) so
|
|
5
|
+
// agent's own tree (never an npm import) so an ability's code is exactly
|
|
6
6
|
// as reviewable/hand-editable as anything the Admin UI's HTTP tool
|
|
7
7
|
// builder already generates (see web/http-tool-admin.ts's own header
|
|
8
8
|
// comment on `generateToolCode` for that same "real code, not an opaque
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
// `threeWayMerge`'s `git merge-file --diff3` — since `loopengine` and
|
|
15
15
|
// `create-loopengine` are separate published packages and a runtime
|
|
16
16
|
// depending on a scaffolding tool (or vice versa) isn't a dependency
|
|
17
|
-
// direction worth introducing to share ~30 lines (see
|
|
17
|
+
// direction worth introducing to share ~30 lines (see ABILITIES.md's
|
|
18
18
|
// "Upgrading" section).
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { basename, join } from 'node:path';
|
|
21
|
+
import { basename, dirname, join } from 'node:path';
|
|
22
22
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
@@ -26,29 +26,99 @@ import semver from 'semver';
|
|
|
26
26
|
import { agentDir } from '../core/gateway-tools.js';
|
|
27
27
|
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
28
28
|
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
29
|
-
export class
|
|
29
|
+
export class AbilityManifestError extends Error {
|
|
30
30
|
}
|
|
31
|
-
export class
|
|
31
|
+
export class AbilityVersionError extends Error {
|
|
32
32
|
}
|
|
33
|
-
export class
|
|
33
|
+
export class AbilityCollisionError extends Error {
|
|
34
34
|
}
|
|
35
|
-
export class
|
|
35
|
+
export class AbilityNotInstalledError extends Error {
|
|
36
36
|
}
|
|
37
|
-
export class
|
|
37
|
+
export class AbilityAlreadyInstalledError extends Error {
|
|
38
38
|
}
|
|
39
39
|
// Same character sets tool names / skill ids are already validated
|
|
40
40
|
// against elsewhere (web/http-tool-admin.ts's TOOL_NAME_PATTERN,
|
|
41
41
|
// web/skills-admin.ts's SKILL_ID_PATTERN) — re-checked here because a
|
|
42
|
-
// name derived from an untrusted
|
|
42
|
+
// name derived from an untrusted ability's own manifest becomes a path
|
|
43
43
|
// segment (agents/<agent>/tools/<name>.ts, .../skills/<id>/): without
|
|
44
|
-
// this,
|
|
44
|
+
// this, an ability declaring a skill dir literally named ".." would have
|
|
45
45
|
// `basename('..')` hand back `'..'` unchanged (path.basename does not
|
|
46
46
|
// strip it), and the later `cpSync` would land one directory up, inside
|
|
47
47
|
// the agent's own root instead of its skills/ folder.
|
|
48
48
|
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
49
49
|
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
50
|
+
/** Two abilities can each declare a skill with the same bare id (both
|
|
51
|
+
* ship a "web-search" skill, say) — refusing the second install outright
|
|
52
|
+
* would make them mutually exclusive for no real reason, since skills
|
|
53
|
+
* don't have to be globally unique the way a model-callable tool name
|
|
54
|
+
* does. Namespacing under the ability's own name only when the bare id
|
|
55
|
+
* is already taken — never unconditionally — keeps the common,
|
|
56
|
+
* no-conflict case's directory layout exactly as it's always been.
|
|
57
|
+
* Reuses SkillGarden's own nested-directory namespacing (discovery.ts:
|
|
58
|
+
* `deploy/web/SKILL.md` -> addressable as `deploy:web`) for free, so a
|
|
59
|
+
* namespaced skill just becomes addressable as `<abilityName>:<skillId>`
|
|
60
|
+
* with no changes needed on the loading side at all. */
|
|
61
|
+
function namespacedSkillId(abilityName, skillId) {
|
|
62
|
+
// abilityName came off the ability's own package.json "name" — for a
|
|
63
|
+
// scoped npm name ("@company/pkg") this is two path segments, which is
|
|
64
|
+
// fine (SkillGarden namespaces however deep the nesting goes), but each
|
|
65
|
+
// segment still has to be a safe path component on its own: same
|
|
66
|
+
// '..'-as-a-path-segment guard TOOL_NAME_PATTERN/SKILL_ID_PATTERN's own
|
|
67
|
+
// comment above already explains the need for, just for a manifest's
|
|
68
|
+
// "name" field instead of a tool/skill declaration.
|
|
69
|
+
for (const segment of abilityName.split('/')) {
|
|
70
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
71
|
+
throw new AbilityManifestError(`Ability name '${abilityName}' isn't safe to use as a skill namespace.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return `${abilityName}/${skillId}`;
|
|
75
|
+
}
|
|
76
|
+
// A tool name (unlike a skill id) is a flat identifier, not a path — it
|
|
77
|
+
// has to satisfy TOOL_NAME_PATTERN on its own (it becomes both a file
|
|
78
|
+
// basename and a JS identifier via toCamelCase), so an ability's raw
|
|
79
|
+
// package.json "name" can't be used directly the way it can as a path
|
|
80
|
+
// segment for a skill (hyphens, "@scope/", dots aren't valid there).
|
|
81
|
+
// Collapsing every run of non-alphanumeric characters to a single "_"
|
|
82
|
+
// and guaranteeing a leading letter (TOOL_NAME_PATTERN requires one)
|
|
83
|
+
// always produces a safe result — never throws, unlike
|
|
84
|
+
// namespacedSkillId, since there's no filesystem path to escape here.
|
|
85
|
+
function sanitizeForToolName(name) {
|
|
86
|
+
const cleaned = name
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
89
|
+
.replace(/^_+|_+$/g, '');
|
|
90
|
+
return cleaned.length > 0 && /^[a-z]/.test(cleaned) ? cleaned : `a_${cleaned}`;
|
|
91
|
+
}
|
|
92
|
+
/** Two abilities can each declare a tool with the same bare name too —
|
|
93
|
+
* but unlike a skill id, a tool's name is the literal, model-callable
|
|
94
|
+
* function name: run-agent.ts's own dedupeToolsByName keeps only the
|
|
95
|
+
* *last* same-named entry and silently drops the rest before the model
|
|
96
|
+
* ever sees them (its own doc comment: "a name collision would still
|
|
97
|
+
* show up twice in toolSchemas, confusing (or rejected outright by) a
|
|
98
|
+
* real model API"), so two same-named tools can never actually coexist
|
|
99
|
+
* as distinct, callable tools the way two same-named skills can — only
|
|
100
|
+
* namespacing the *name itself* avoids that silent drop. */
|
|
101
|
+
function namespacedToolName(abilityName, toolName) {
|
|
102
|
+
return `${sanitizeForToolName(abilityName)}__${toolName}`;
|
|
103
|
+
}
|
|
104
|
+
/** Whether `manifestToolFile` (a bare path straight off a manifest, e.g.
|
|
105
|
+
* "tools/web_search.ts") is the file an installed `installedToolName`
|
|
106
|
+
* (bare, or namespaced under `abilityName` on a collision — see
|
|
107
|
+
* namespacedToolName) actually came from. Used by upgradeAbility to
|
|
108
|
+
* match a provenance-recorded, possibly-namespaced name back to the
|
|
109
|
+
* right manifest entry — comparing the *installed* name against a
|
|
110
|
+
* freshly-recomputed candidate, rather than trying to strip a namespace
|
|
111
|
+
* prefix back off `installedToolName` textually, since "<ability>__"
|
|
112
|
+
* isn't a structural separator the way a skill id's "/" is: a bare tool
|
|
113
|
+
* name could legitimately contain "__" on its own, so string-stripping
|
|
114
|
+
* it back off would be ambiguous in a way recomputing forward and
|
|
115
|
+
* comparing never is. */
|
|
116
|
+
function matchesInstalledToolName(abilityName, manifestToolFile, installedToolName) {
|
|
117
|
+
const bareToolName = basename(manifestToolFile, '.ts');
|
|
118
|
+
return bareToolName === installedToolName || namespacedToolName(abilityName, bareToolName) === installedToolName;
|
|
119
|
+
}
|
|
50
120
|
function provenancePath(agentName) {
|
|
51
|
-
return join(agentDir(agentName), '.loopengine-
|
|
121
|
+
return join(agentDir(agentName), '.loopengine-abilities.json');
|
|
52
122
|
}
|
|
53
123
|
function readProvenance(agentName) {
|
|
54
124
|
const path = provenancePath(agentName);
|
|
@@ -69,10 +139,10 @@ function sha256(content) {
|
|
|
69
139
|
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
70
140
|
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
71
141
|
* — the last of which is what this module's own tests point at, so no
|
|
72
|
-
* real network call is needed there; see
|
|
73
|
-
*
|
|
74
|
-
export function
|
|
75
|
-
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-
|
|
142
|
+
* real network call is needed there; see ABILITIES.md's "Publishing an
|
|
143
|
+
* ability" section). */
|
|
144
|
+
export function fetchAbilityDir(spec) {
|
|
145
|
+
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-'));
|
|
76
146
|
execFileSync('npm', ['pack', spec, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
77
147
|
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
78
148
|
if (!tarball) {
|
|
@@ -85,22 +155,33 @@ export function fetchPackageDir(spec) {
|
|
|
85
155
|
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
86
156
|
return join(workDir, 'package');
|
|
87
157
|
}
|
|
88
|
-
function readManifest(
|
|
89
|
-
const manifestPath = join(
|
|
158
|
+
function readManifest(abilityDir) {
|
|
159
|
+
const manifestPath = join(abilityDir, 'loopengine.ability.json');
|
|
90
160
|
if (!existsSync(manifestPath)) {
|
|
91
|
-
throw new
|
|
161
|
+
throw new AbilityManifestError(`${abilityDir} has no loopengine.ability.json — not a valid loopengine ability.`);
|
|
162
|
+
}
|
|
163
|
+
const declared = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
164
|
+
if (!declared.loopengineVersion) {
|
|
165
|
+
throw new AbilityManifestError('loopengine.ability.json must have "loopengineVersion".');
|
|
92
166
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
167
|
+
// name/version come from package.json, not loopengine.ability.json —
|
|
168
|
+
// see AbilityManifest's own doc comment for why duplicating them here
|
|
169
|
+
// would just be two numbers to keep in sync instead of one.
|
|
170
|
+
const pkgJsonPath = join(abilityDir, 'package.json');
|
|
171
|
+
if (!existsSync(pkgJsonPath)) {
|
|
172
|
+
throw new AbilityManifestError(`${abilityDir} has no package.json — every loopengine ability needs one (name/version), even though its own metadata lives in loopengine.ability.json.`);
|
|
96
173
|
}
|
|
97
|
-
|
|
174
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
175
|
+
if (!pkgJson.name || !pkgJson.version) {
|
|
176
|
+
throw new AbilityManifestError(`${pkgJsonPath} must have "name" and "version".`);
|
|
177
|
+
}
|
|
178
|
+
return { ...declared, name: pkgJson.name, version: pkgJson.version };
|
|
98
179
|
}
|
|
99
180
|
// The installing project's own dependencies.loopengine is itself a
|
|
100
181
|
// range (e.g. "^0.1.10"), not a concrete installed version — checking
|
|
101
182
|
// the *floor* of that range against the manifest's required range is
|
|
102
183
|
// the conservative choice: if even the lowest version the range could
|
|
103
|
-
// resolve to wouldn't satisfy the
|
|
184
|
+
// resolve to wouldn't satisfy the ability, refuse now rather than risk
|
|
104
185
|
// the exact silent-failure gap the Parallel-safe checkbox already hit
|
|
105
186
|
// (a feature that imports an export an *actually installed* older
|
|
106
187
|
// version doesn't have yet, with no error, just quietly not working).
|
|
@@ -131,7 +212,7 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
|
131
212
|
return;
|
|
132
213
|
installedRange = ownPkg.dependencies?.loopengine;
|
|
133
214
|
if (!installedRange) {
|
|
134
|
-
throw new
|
|
215
|
+
throw new AbilityVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
|
|
135
216
|
}
|
|
136
217
|
}
|
|
137
218
|
// A non-registry dependency specifier (file:, git:, workspace:, ...) —
|
|
@@ -149,19 +230,19 @@ function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
|
149
230
|
return;
|
|
150
231
|
}
|
|
151
232
|
if (!floor || !semver.satisfies(floor, manifest.loopengineVersion)) {
|
|
152
|
-
throw new
|
|
233
|
+
throw new AbilityVersionError(`Ability '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
|
|
153
234
|
}
|
|
154
235
|
}
|
|
155
|
-
function parseActauthRules(
|
|
236
|
+
function parseActauthRules(abilityDir, manifest) {
|
|
156
237
|
if (!manifest.actauth)
|
|
157
238
|
return [];
|
|
158
|
-
const rulesPath = join(
|
|
239
|
+
const rulesPath = join(abilityDir, manifest.actauth);
|
|
159
240
|
if (!existsSync(rulesPath)) {
|
|
160
|
-
throw new
|
|
241
|
+
throw new AbilityManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the ability.`);
|
|
161
242
|
}
|
|
162
243
|
const parsed = parseYaml(readFileSync(rulesPath, 'utf8'));
|
|
163
244
|
if (!Array.isArray(parsed)) {
|
|
164
|
-
throw new
|
|
245
|
+
throw new AbilityManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
|
|
165
246
|
}
|
|
166
247
|
return parsed;
|
|
167
248
|
}
|
|
@@ -171,7 +252,7 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
171
252
|
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
172
253
|
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
173
254
|
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
174
|
-
* frontmatter from scratch and can't carry
|
|
255
|
+
* frontmatter from scratch and can't carry an ability's own
|
|
175
256
|
* frontmatter/assets), and appends each actauth rule (addActauthRule).
|
|
176
257
|
*
|
|
177
258
|
* Deliberately does *not* attempt a live in-memory registry splice the
|
|
@@ -179,7 +260,7 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
179
260
|
* admin-created tool — that works there because the HTTP request
|
|
180
261
|
* creating the tool runs *inside the same process* as the already-running
|
|
181
262
|
* server, so updateAgent's mutation is visible to every later request in
|
|
182
|
-
* that same process immediately. `add-
|
|
263
|
+
* that same process immediately. `add-ability` is a separate, one-off
|
|
183
264
|
* CLI process with no connection to whatever server might be running
|
|
184
265
|
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
185
266
|
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
@@ -196,50 +277,61 @@ function parseActauthRules(packageDir, manifest) {
|
|
|
196
277
|
*
|
|
197
278
|
* All-or-nothing: every collision check below runs, and the whole
|
|
198
279
|
* install is refused, before a single file is written. */
|
|
199
|
-
export async function
|
|
200
|
-
const fetch = options.
|
|
201
|
-
const
|
|
202
|
-
const manifest = readManifest(
|
|
280
|
+
export async function installAbility(agentName, spec, options = {}) {
|
|
281
|
+
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
282
|
+
const abilityDir = fetch(spec);
|
|
283
|
+
const manifest = readManifest(abilityDir);
|
|
203
284
|
checkLoopengineVersion(manifest, options.installedLoopengineRange);
|
|
204
285
|
const provenance = readProvenance(agentName);
|
|
205
286
|
if (provenance[manifest.name]) {
|
|
206
|
-
throw new
|
|
287
|
+
throw new AbilityAlreadyInstalledError(`Ability '${manifest.name}' is already installed for '${agentName}' — use upgrade-ability instead.`);
|
|
207
288
|
}
|
|
208
289
|
const toolFiles = manifest.tools ?? [];
|
|
209
290
|
const skillDirs = manifest.skills ?? [];
|
|
210
|
-
const rules = parseActauthRules(
|
|
291
|
+
const rules = parseActauthRules(abilityDir, manifest);
|
|
211
292
|
const toolsDir = join(agentDir(agentName), 'tools');
|
|
212
293
|
const skillsDirPath = join(agentDir(agentName), 'skills');
|
|
213
294
|
// Collision checks — refuse the whole install before writing anything,
|
|
214
295
|
// same "refuse rather than guess" rule HttpToolExistsError/
|
|
215
296
|
// HttpToolIndexShapeError already enforce for a single admin-created
|
|
216
|
-
// tool, just applied
|
|
297
|
+
// tool, just applied ability-wide.
|
|
217
298
|
const toolNames = [];
|
|
218
299
|
for (const toolFile of toolFiles) {
|
|
219
|
-
const
|
|
220
|
-
if (!TOOL_NAME_PATTERN.test(
|
|
221
|
-
throw new
|
|
300
|
+
const bareToolName = basename(toolFile, '.ts');
|
|
301
|
+
if (!TOOL_NAME_PATTERN.test(bareToolName)) {
|
|
302
|
+
throw new AbilityManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
|
|
222
303
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
304
|
+
// The bare name if it's free; otherwise namespace under this
|
|
305
|
+
// ability's own name rather than refusing the install outright —
|
|
306
|
+
// see namespacedToolName's own doc comment for why this still
|
|
307
|
+
// leaves both tools genuinely callable, unlike a same-named actauth
|
|
308
|
+
// rule (below), which has no such option.
|
|
309
|
+
const installedToolName = existsSync(join(toolsDir, `${bareToolName}.ts`)) ? namespacedToolName(manifest.name, bareToolName) : bareToolName;
|
|
310
|
+
if (existsSync(join(toolsDir, `${installedToolName}.ts`))) {
|
|
311
|
+
throw new AbilityCollisionError(`agents/${agentName}/tools/${installedToolName}.ts already exists.`);
|
|
226
312
|
}
|
|
313
|
+
toolNames.push(installedToolName);
|
|
227
314
|
}
|
|
228
315
|
const skillIds = [];
|
|
229
316
|
for (const skillDir of skillDirs) {
|
|
230
|
-
const
|
|
231
|
-
if (!SKILL_ID_PATTERN.test(
|
|
232
|
-
throw new
|
|
317
|
+
const bareSkillId = basename(skillDir);
|
|
318
|
+
if (!SKILL_ID_PATTERN.test(bareSkillId)) {
|
|
319
|
+
throw new AbilityManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
|
|
233
320
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
321
|
+
// The bare id if it's free; otherwise namespace under this ability's
|
|
322
|
+
// own name rather than refusing the install outright — see
|
|
323
|
+
// namespacedSkillId's own doc comment for why this is safe to do
|
|
324
|
+
// (skills, unlike tools, don't need a single global namespace).
|
|
325
|
+
const installedSkillId = existsSync(join(skillsDirPath, bareSkillId)) ? namespacedSkillId(manifest.name, bareSkillId) : bareSkillId;
|
|
326
|
+
if (existsSync(join(skillsDirPath, installedSkillId))) {
|
|
327
|
+
throw new AbilityCollisionError(`agents/${agentName}/skills/${installedSkillId}/ already exists.`);
|
|
237
328
|
}
|
|
329
|
+
skillIds.push(installedSkillId);
|
|
238
330
|
}
|
|
239
331
|
const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
|
|
240
332
|
for (const rule of rules) {
|
|
241
333
|
if (existingRuleNames.has(rule.name)) {
|
|
242
|
-
throw new
|
|
334
|
+
throw new AbilityCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
|
|
243
335
|
}
|
|
244
336
|
}
|
|
245
337
|
const contentHashes = {};
|
|
@@ -250,27 +342,51 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
250
342
|
if (toolNames.length > 0) {
|
|
251
343
|
mkdirSync(toolsDir, { recursive: true });
|
|
252
344
|
const indexPath = join(toolsDir, 'index.ts');
|
|
253
|
-
for (const toolFile of toolFiles) {
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
345
|
+
for (const [i, toolFile] of toolFiles.entries()) {
|
|
346
|
+
const bareToolName = basename(toolFile, '.ts');
|
|
347
|
+
const installedToolName = toolNames[i];
|
|
348
|
+
const code = readFileSync(join(abilityDir, toolFile), 'utf8');
|
|
349
|
+
const destPath = join(toolsDir, `${installedToolName}.ts`);
|
|
257
350
|
writeFileSync(destPath, code);
|
|
258
|
-
contentHashes[`tools/${
|
|
259
|
-
|
|
351
|
+
contentHashes[`tools/${installedToolName}.ts`] = sha256(code);
|
|
352
|
+
// Plain case: the file's own export is imported and used as-is,
|
|
353
|
+
// exactly as before. Namespaced case: the file's own internal
|
|
354
|
+
// export name is untouched (still whatever the ability itself
|
|
355
|
+
// authored — importing it under that same bare name from two
|
|
356
|
+
// different abilities' files would be a duplicate top-level
|
|
357
|
+
// identifier), so the import gets aliased, and a small wrapper
|
|
358
|
+
// `const` overrides the actual model-facing `name` field, which
|
|
359
|
+
// the JS identifier alone has no effect on. See addToolToIndex's
|
|
360
|
+
// own doc comment for why `importSpecifier`/`arrayExpression` are
|
|
361
|
+
// separate parameters.
|
|
362
|
+
const sourceExportName = toCamelCase(bareToolName);
|
|
363
|
+
const namespaced = installedToolName !== bareToolName;
|
|
364
|
+
const arrayExpression = namespaced ? toCamelCase(installedToolName) : sourceExportName;
|
|
365
|
+
const importSpecifier = namespaced ? `${sourceExportName} as ${arrayExpression}Source` : sourceExportName;
|
|
366
|
+
const preamble = namespaced ? `const ${arrayExpression}: ToolDefinition = { ...${arrayExpression}Source, name: '${installedToolName}' }` : undefined;
|
|
260
367
|
if (existsSync(indexPath)) {
|
|
261
|
-
addToolToIndex(indexPath,
|
|
368
|
+
addToolToIndex(indexPath, installedToolName, importSpecifier, arrayExpression, preamble);
|
|
262
369
|
}
|
|
263
370
|
else {
|
|
264
|
-
|
|
371
|
+
const preambleBlock = preamble ? `${preamble}\n\n` : '';
|
|
372
|
+
writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${importSpecifier} } from './${installedToolName}.js'\n\n${preambleBlock}export const tools: ToolDefinition[] = [${arrayExpression}]\n`);
|
|
265
373
|
}
|
|
266
374
|
}
|
|
267
375
|
}
|
|
268
|
-
// Copy skill directories verbatim
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
376
|
+
// Copy skill directories verbatim — skillIds[i] is skillDirs[i]'s
|
|
377
|
+
// already-resolved installed id (bare, or namespaced under this
|
|
378
|
+
// ability's own name if the bare id collided — computed once, above,
|
|
379
|
+
// not recomputed here, so this loop can't disagree with the collision
|
|
380
|
+
// check that already ran against it).
|
|
381
|
+
for (const [i, skillDir] of skillDirs.entries()) {
|
|
382
|
+
const skillId = skillIds[i];
|
|
383
|
+
const srcPath = join(abilityDir, skillDir);
|
|
272
384
|
const destPath = join(skillsDirPath, skillId);
|
|
273
|
-
|
|
385
|
+
// dirname(destPath), not skillsDirPath — a namespaced skillId
|
|
386
|
+
// ("<abilityName>/<skillId>") needs its own intermediate directory
|
|
387
|
+
// created first; cpSync creates destPath itself but not necessarily
|
|
388
|
+
// parents beyond that.
|
|
389
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
274
390
|
cpSync(srcPath, destPath, { recursive: true });
|
|
275
391
|
const skillMdPath = join(destPath, 'SKILL.md');
|
|
276
392
|
if (existsSync(skillMdPath)) {
|
|
@@ -299,7 +415,7 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
299
415
|
// threeWayMerge already documents. Operates on a disposable scratch
|
|
300
416
|
// copy, never the real project file directly.
|
|
301
417
|
function mergeFile(mine, base, theirs) {
|
|
302
|
-
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-
|
|
418
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-ability-merge-'));
|
|
303
419
|
const minePath = join(scratchDir, 'mine');
|
|
304
420
|
const basePath = join(scratchDir, 'base');
|
|
305
421
|
const theirsPath = join(scratchDir, 'theirs');
|
|
@@ -341,13 +457,13 @@ const PINNED_SPEC = /^(git\+|git:|https?:|file:|\.\.?\/|\/)/;
|
|
|
341
457
|
* would still resolve there today. A git/file/URL spec is returned
|
|
342
458
|
* as-is — see PINNED_SPEC's own doc comment for why appending "@version"
|
|
343
459
|
* to one of those breaks instead of pinning. `recordedSpec` is only
|
|
344
|
-
* absent for
|
|
345
|
-
* existed; falling back to `
|
|
460
|
+
* absent for an ability installed before InstalledAbilityRecord.spec
|
|
461
|
+
* existed; falling back to `abilityName` there reproduces this
|
|
346
462
|
* function's own old (buggy for a git/file install) behavior exactly —
|
|
347
|
-
* no worse than before, and only for
|
|
463
|
+
* no worse than before, and only for an ability that hasn't upgraded
|
|
348
464
|
* since. */
|
|
349
|
-
function oldContentSpec(recordedSpec,
|
|
350
|
-
const base = recordedSpec ??
|
|
465
|
+
function oldContentSpec(recordedSpec, abilityName, version) {
|
|
466
|
+
const base = recordedSpec ?? abilityName;
|
|
351
467
|
if (PINNED_SPEC.test(base))
|
|
352
468
|
return base;
|
|
353
469
|
// Strip any version/tag the spec already carries (a scoped name's own
|
|
@@ -357,27 +473,27 @@ function oldContentSpec(recordedSpec, packageName, version) {
|
|
|
357
473
|
const bareName = base.startsWith('@') ? `@${base.slice(1).split('@')[0]}` : base.split('@')[0];
|
|
358
474
|
return `${bareName}@${version}`;
|
|
359
475
|
}
|
|
360
|
-
/** Upgrades an already-installed
|
|
361
|
-
* to `
|
|
476
|
+
/** Upgrades an already-installed ability to whatever `spec` (defaulting
|
|
477
|
+
* to `abilityName`, i.e. "latest") currently resolves to. Tool and skill
|
|
362
478
|
* files get a real three-way merge (mine = current file, possibly
|
|
363
479
|
* hand-edited since install; base = the version recorded at
|
|
364
480
|
* install/last-upgrade, refetched via oldContentSpec; theirs = newly
|
|
365
481
|
* fetched) — identical technique to `create-loopengine upgrade`, just
|
|
366
|
-
* applied to
|
|
482
|
+
* applied to ability-managed files instead of template files. actauth
|
|
367
483
|
* rules upgrade per-rule instead: the target actauth.yml holds rules
|
|
368
|
-
* from other
|
|
484
|
+
* from other abilities and hand-written ones too, so there's no coherent
|
|
369
485
|
* "whole file" base/theirs to merge — a rule unchanged since install
|
|
370
486
|
* updates cleanly; a hand-edited one is left alone and reported as a
|
|
371
487
|
* conflict rather than overwritten. */
|
|
372
|
-
export async function
|
|
373
|
-
const fetch = options.
|
|
488
|
+
export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
489
|
+
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
374
490
|
const provenance = readProvenance(agentName);
|
|
375
|
-
const record = provenance[
|
|
491
|
+
const record = provenance[abilityName];
|
|
376
492
|
if (!record) {
|
|
377
|
-
throw new
|
|
493
|
+
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
|
|
378
494
|
}
|
|
379
|
-
const spec = options.spec ??
|
|
380
|
-
const oldDir = fetch(oldContentSpec(record.spec,
|
|
495
|
+
const spec = options.spec ?? abilityName;
|
|
496
|
+
const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
|
|
381
497
|
const newDir = fetch(spec);
|
|
382
498
|
const oldManifest = readManifest(oldDir);
|
|
383
499
|
const newManifest = readManifest(newDir);
|
|
@@ -386,8 +502,12 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
386
502
|
const newContentHashes = { ...record.contentHashes };
|
|
387
503
|
for (const toolName of record.tools) {
|
|
388
504
|
const relPath = `tools/${toolName}.ts`;
|
|
389
|
-
|
|
390
|
-
|
|
505
|
+
// matchesInstalledToolName, not a bare basename(f, '.ts') === toolName
|
|
506
|
+
// comparison — toolName may be namespaced (see namespacedToolName),
|
|
507
|
+
// and "<ability>__" isn't a structural separator safe to strip back
|
|
508
|
+
// off textually the way a skill id's "/" is.
|
|
509
|
+
const oldFile = (oldManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
510
|
+
const newFile = (newManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
391
511
|
if (!oldFile || !newFile) {
|
|
392
512
|
results.push({ path: relPath, status: 'unchanged' });
|
|
393
513
|
continue;
|
|
@@ -407,8 +527,13 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
407
527
|
}
|
|
408
528
|
for (const skillId of record.skills) {
|
|
409
529
|
const relPath = `skills/${skillId}/SKILL.md`;
|
|
410
|
-
|
|
411
|
-
|
|
530
|
+
// basename(skillId), not skillId itself — a namespaced install
|
|
531
|
+
// ("<abilityName>/<skillId>") still has to match against the
|
|
532
|
+
// manifest's own bare skill dir names, which never carry the
|
|
533
|
+
// namespace (that's only ever added at install time, on collision).
|
|
534
|
+
const bareSkillId = basename(skillId);
|
|
535
|
+
const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
|
|
536
|
+
const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
|
|
412
537
|
const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
|
|
413
538
|
const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
|
|
414
539
|
if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
|
|
@@ -452,7 +577,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
452
577
|
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
453
578
|
results.push({ path, status: 'updated' });
|
|
454
579
|
}
|
|
455
|
-
provenance[
|
|
580
|
+
provenance[abilityName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
456
581
|
writeProvenance(agentName, provenance);
|
|
457
582
|
return { files: results };
|
|
458
583
|
}
|
|
@@ -464,21 +589,21 @@ function isDirty(path, recordedHash) {
|
|
|
464
589
|
return true;
|
|
465
590
|
return sha256(readFileSync(path, 'utf8')) !== recordedHash;
|
|
466
591
|
}
|
|
467
|
-
/** Removes an installed
|
|
592
|
+
/** Removes an installed ability's tool and skill files, and its actauth
|
|
468
593
|
* rules. A file whose content no longer matches the hash recorded at
|
|
469
594
|
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
470
|
-
* unless `force` is passed — a concrete implementation of
|
|
595
|
+
* unless `force` is passed — a concrete implementation of ABILITIES.md's
|
|
471
596
|
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
472
|
-
* content or refetching the
|
|
597
|
+
* content or refetching the ability to diff. Does not patch
|
|
473
598
|
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
474
599
|
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
475
600
|
* comment already applies; a stale import surfaces as a clear build
|
|
476
601
|
* error, not a silent break. */
|
|
477
|
-
export function
|
|
602
|
+
export function removeAbility(agentName, abilityName, force = false) {
|
|
478
603
|
const provenance = readProvenance(agentName);
|
|
479
|
-
const record = provenance[
|
|
604
|
+
const record = provenance[abilityName];
|
|
480
605
|
if (!record) {
|
|
481
|
-
throw new
|
|
606
|
+
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
|
|
482
607
|
}
|
|
483
608
|
const removed = [];
|
|
484
609
|
const refused = [];
|
|
@@ -494,10 +619,10 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
494
619
|
continue;
|
|
495
620
|
}
|
|
496
621
|
rmSync(fullPath, { force: true });
|
|
497
|
-
// Undoes
|
|
622
|
+
// Undoes installAbility's own addToolToIndex call — without this, a
|
|
498
623
|
// deleted tool's import survives in tools/index.ts, and either fails
|
|
499
624
|
// the next build (a dangling import to a file that no longer exists)
|
|
500
|
-
// or, worse, silently duplicates on a later add-
|
|
625
|
+
// or, worse, silently duplicates on a later add-ability of the same
|
|
501
626
|
// tool (addToolToIndex used to have no idempotence check at all —
|
|
502
627
|
// now it does, but this is the actual fix: the stale entry shouldn't
|
|
503
628
|
// be there to begin with).
|
|
@@ -525,10 +650,10 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
525
650
|
}
|
|
526
651
|
}
|
|
527
652
|
if (refused.length === 0) {
|
|
528
|
-
delete provenance[
|
|
653
|
+
delete provenance[abilityName];
|
|
529
654
|
}
|
|
530
655
|
else {
|
|
531
|
-
provenance[
|
|
656
|
+
provenance[abilityName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
|
|
532
657
|
}
|
|
533
658
|
writeProvenance(agentName, provenance);
|
|
534
659
|
return { removed, refused };
|