loopengine 0.1.10 → 0.1.12
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/dist/bin/cli.js +104 -0
- package/dist/bin/package-manager.d.ts +128 -0
- package/dist/bin/package-manager.js +490 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/web/agents-config-page.js +98 -1
- package/dist/web/env-admin.d.ts +26 -0
- package/dist/web/env-admin.js +97 -0
- package/dist/web/http-tool-admin.d.ts +2 -0
- package/dist/web/http-tool-admin.js +2 -2
- package/package.json +5 -1
package/dist/bin/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { installPackage, upgradePackage, removePackage } from './package-manager.js';
|
|
16
17
|
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
17
18
|
export class AgentNameError extends Error {
|
|
18
19
|
}
|
|
@@ -230,6 +231,24 @@ async function requireAdapterFile(relPath) {
|
|
|
230
231
|
process.exitCode = 1;
|
|
231
232
|
return false;
|
|
232
233
|
}
|
|
234
|
+
// Shared by add-package/upgrade-package/remove-package below — pulls a
|
|
235
|
+
// `--flag value` pair out of argv, same inline style `run`'s own
|
|
236
|
+
// `--input` handling already uses, factored out only because three
|
|
237
|
+
// commands need the identical `--agent <name>` extraction (a single use
|
|
238
|
+
// isn't worth a helper; three is).
|
|
239
|
+
function extractFlagValue(args, flag) {
|
|
240
|
+
const index = args.indexOf(flag);
|
|
241
|
+
if (index === -1)
|
|
242
|
+
return { value: undefined, rest: args };
|
|
243
|
+
const value = args[index + 1];
|
|
244
|
+
return { value, rest: [...args.slice(0, index), ...args.slice(index + 2)] };
|
|
245
|
+
}
|
|
246
|
+
function extractFlagPresence(args, flag) {
|
|
247
|
+
const index = args.indexOf(flag);
|
|
248
|
+
if (index === -1)
|
|
249
|
+
return { present: false, rest: args };
|
|
250
|
+
return { present: true, rest: [...args.slice(0, index), ...args.slice(index + 1)] };
|
|
251
|
+
}
|
|
233
252
|
async function main() {
|
|
234
253
|
const [, , command, ...rest] = process.argv;
|
|
235
254
|
if (command === 'add-agent') {
|
|
@@ -282,6 +301,88 @@ async function main() {
|
|
|
282
301
|
console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
|
|
283
302
|
return;
|
|
284
303
|
}
|
|
304
|
+
// Installs a loopengine package (see PACKAGES.md) — a bundle of tool
|
|
305
|
+
// files, skill directories, and actauth rules published as an ordinary
|
|
306
|
+
// npm package (public, private-registry, git, or a local file: path),
|
|
307
|
+
// copied into the target agent's own tree rather than added as a
|
|
308
|
+
// node_modules import (see package-manager.ts's own header comment for
|
|
309
|
+
// why). <spec> is whatever `npm pack` itself accepts.
|
|
310
|
+
if (command === 'add-package') {
|
|
311
|
+
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
312
|
+
const [spec] = withoutAgent;
|
|
313
|
+
if (!spec || !agent) {
|
|
314
|
+
console.error('Usage: loopengine add-package <spec> --agent <name>');
|
|
315
|
+
process.exitCode = 1;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const { installed } = await installPackage(agent, spec);
|
|
320
|
+
console.log(`Installed into agents/${agent}/:`);
|
|
321
|
+
for (const p of installed)
|
|
322
|
+
console.log(` ${p}`);
|
|
323
|
+
console.log();
|
|
324
|
+
console.log('Already running under `npx loopengine dev`? The new tool becomes active automatically.');
|
|
325
|
+
console.log('Running under `serve` (or nothing yet)? Restart the server to pick it up.');
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Pulls in whatever <packageName> (or --spec, if the package moved)
|
|
334
|
+
// currently resolves to, three-way-merging each tool/skill file it
|
|
335
|
+
// manages against what changed upstream since install — a hand-edit
|
|
336
|
+
// survives a clean merge; a real conflict leaves <<<<<<< markers to
|
|
337
|
+
// resolve by hand, same as create-loopengine upgrade already does for
|
|
338
|
+
// template files.
|
|
339
|
+
if (command === 'upgrade-package') {
|
|
340
|
+
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
341
|
+
const { value: spec, rest: withoutSpec } = extractFlagValue(withoutAgent, '--spec');
|
|
342
|
+
const [packageName] = withoutSpec;
|
|
343
|
+
if (!packageName || !agent) {
|
|
344
|
+
console.error('Usage: loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const { files } = await upgradePackage(agent, packageName, spec ? { spec } : {});
|
|
350
|
+
for (const f of files)
|
|
351
|
+
console.log(` ${f.status.padEnd(9)}${f.path}`);
|
|
352
|
+
const conflicts = files.filter((f) => f.status === 'conflict');
|
|
353
|
+
console.log(conflicts.length > 0 ? `${conflicts.length} file(s)/rule(s) need resolving by hand.` : 'No conflicts.');
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
357
|
+
process.exitCode = 1;
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Removes a package's tool/skill files and actauth rules — refuses a
|
|
362
|
+
// file whose content no longer matches what was recorded at
|
|
363
|
+
// install/last-upgrade (a hand-edit since) unless --force is given.
|
|
364
|
+
if (command === 'remove-package') {
|
|
365
|
+
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
366
|
+
const { present: force, rest: withoutForce } = extractFlagPresence(withoutAgent, '--force');
|
|
367
|
+
const [packageName] = withoutForce;
|
|
368
|
+
if (!packageName || !agent) {
|
|
369
|
+
console.error('Usage: loopengine remove-package <packageName> --agent <name> [--force]');
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const { removed, refused } = removePackage(agent, packageName, force);
|
|
375
|
+
for (const p of removed)
|
|
376
|
+
console.log(` removed ${p}`);
|
|
377
|
+
for (const p of refused)
|
|
378
|
+
console.log(` refused ${p} (hand-modified since install — pass --force to remove anyway)`);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
382
|
+
process.exitCode = 1;
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
285
386
|
// One-shot: sends one message, prints the reply, exits — exactly
|
|
286
387
|
// adapters/cli.ts's own contract (see its own header comment), not a
|
|
287
388
|
// REPL. `rest` after `<agent>` is forwarded through as-is (--session,
|
|
@@ -362,6 +463,9 @@ async function main() {
|
|
|
362
463
|
}
|
|
363
464
|
console.error('Usage: loopengine add-agent <name>');
|
|
364
465
|
console.error(' loopengine add-subagent <parent> <name>');
|
|
466
|
+
console.error(' loopengine add-package <spec> --agent <name>');
|
|
467
|
+
console.error(' loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
|
|
468
|
+
console.error(' loopengine remove-package <packageName> --agent <name> [--force]');
|
|
365
469
|
console.error(' loopengine run <agent> [--session <id>] "<message>"');
|
|
366
470
|
console.error(' loopengine serve');
|
|
367
471
|
console.error(' loopengine dev');
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export declare class PackageManifestError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export declare class PackageVersionError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare class PackageCollisionError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export declare class PackageNotInstalledError extends Error {
|
|
8
|
+
}
|
|
9
|
+
export declare class PackageAlreadyInstalledError extends Error {
|
|
10
|
+
}
|
|
11
|
+
export interface PackageEnvDecl {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
secret?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface PackageManifest {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
loopengineVersion: string;
|
|
20
|
+
tools?: string[];
|
|
21
|
+
skills?: string[];
|
|
22
|
+
actauth?: string;
|
|
23
|
+
env?: PackageEnvDecl[];
|
|
24
|
+
}
|
|
25
|
+
/** One agent's record of one installed package — the merge base a
|
|
26
|
+
* future upgrade needs, what remove needs to know is safe to delete,
|
|
27
|
+
* and (env) what the Admin UI's secrets section reads to know which
|
|
28
|
+
* vars to prompt for. Written to agents/<agent>/.loopengine-packages.json,
|
|
29
|
+
* same role .create-loopengine.json already plays for template files. */
|
|
30
|
+
export interface InstalledPackageRecord {
|
|
31
|
+
version: string;
|
|
32
|
+
tools: string[];
|
|
33
|
+
skills: string[];
|
|
34
|
+
actauthRules: string[];
|
|
35
|
+
env: PackageEnvDecl[];
|
|
36
|
+
/** relative path (e.g. "tools/foo.ts") -> sha256 hex, as of the last
|
|
37
|
+
* install/upgrade — remove-package's dirty-check compares against
|
|
38
|
+
* this rather than storing/refetching full content to diff. */
|
|
39
|
+
contentHashes: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
/** `npm pack <spec>` + tar extract — generalized from
|
|
42
|
+
* create-loopengine's own `fetchPublishedTemplateDir` (that function
|
|
43
|
+
* only ever fetches the hardcoded `create-loopengine@<version>`; this
|
|
44
|
+
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
45
|
+
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
46
|
+
* — the last of which is what this module's own tests point at, so no
|
|
47
|
+
* real network call is needed there; see PACKAGES.md's "Publishing a
|
|
48
|
+
* package" section). */
|
|
49
|
+
export declare function fetchPackageDir(spec: string): string;
|
|
50
|
+
export interface FetchOptions {
|
|
51
|
+
fetchPackageDir?: (spec: string) => string;
|
|
52
|
+
/** Test-only override for the installing project's own "loopengine"
|
|
53
|
+
* dependency range — defaults to a real package.json read. Without
|
|
54
|
+
* this, checkLoopengineVersion's refusal path is unreachable from
|
|
55
|
+
* this repo's own test suite: this repo's package.json has no
|
|
56
|
+
* self-dependency to check against (see that function's own doc
|
|
57
|
+
* comment), so every real test run would silently skip the check
|
|
58
|
+
* instead of exercising it. */
|
|
59
|
+
installedLoopengineRange?: string;
|
|
60
|
+
}
|
|
61
|
+
/** Installs `spec` (anything `npm pack` accepts) for `agentName`:
|
|
62
|
+
* writes each manifest-listed tool file and patches tools/index.ts
|
|
63
|
+
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
64
|
+
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
65
|
+
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
66
|
+
* frontmatter from scratch and can't carry a package's own
|
|
67
|
+
* frontmatter/assets), and appends each actauth rule (addActauthRule).
|
|
68
|
+
*
|
|
69
|
+
* Deliberately does *not* attempt a live in-memory registry splice the
|
|
70
|
+
* way adapters/http.ts's own handleHttpToolPost does for an
|
|
71
|
+
* admin-created tool — that works there because the HTTP request
|
|
72
|
+
* creating the tool runs *inside the same process* as the already-running
|
|
73
|
+
* server, so updateAgent's mutation is visible to every later request in
|
|
74
|
+
* that same process immediately. `add-package` is a separate, one-off
|
|
75
|
+
* CLI process with no connection to whatever server might be running
|
|
76
|
+
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
77
|
+
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
78
|
+
* vanish the moment the process exits, having done nothing to the real
|
|
79
|
+
* running server. (Confirmed live: this used to import
|
|
80
|
+
* core/agent-registry.js, whose own discoverAgents does a top-level
|
|
81
|
+
* directory scan relative to *its own compiled file's location* —
|
|
82
|
+
* inside node_modules/loopengine when this runs there, not the
|
|
83
|
+
* consuming project's real agents/ at all, throwing ENOENT immediately.)
|
|
84
|
+
* Same reason add-agent/add-subagent above don't attempt this either —
|
|
85
|
+
* a new tool becomes active the same way a new agent does: the next
|
|
86
|
+
* request under `serve`, or automatically under `npx loopengine dev`'s
|
|
87
|
+
* file watcher once tools/index.ts's own edit is picked up.
|
|
88
|
+
*
|
|
89
|
+
* All-or-nothing: every collision check below runs, and the whole
|
|
90
|
+
* install is refused, before a single file is written. */
|
|
91
|
+
export declare function installPackage(agentName: string, spec: string, options?: FetchOptions): Promise<{
|
|
92
|
+
installed: string[];
|
|
93
|
+
}>;
|
|
94
|
+
export interface UpgradeFileResult {
|
|
95
|
+
path: string;
|
|
96
|
+
status: 'updated' | 'unchanged' | 'conflict';
|
|
97
|
+
}
|
|
98
|
+
/** Upgrades an already-installed package to whatever `spec` (defaulting
|
|
99
|
+
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
100
|
+
* files get a real three-way merge (mine = current file, possibly
|
|
101
|
+
* hand-edited since install; base = the version recorded at
|
|
102
|
+
* install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
|
|
103
|
+
* newly fetched) — identical technique to `create-loopengine upgrade`,
|
|
104
|
+
* just applied to package-managed files instead of template files.
|
|
105
|
+
* actauth rules upgrade per-rule instead: the target actauth.yml holds
|
|
106
|
+
* rules from other packages and hand-written ones too, so there's no
|
|
107
|
+
* coherent "whole file" base/theirs to merge — a rule unchanged since
|
|
108
|
+
* install updates cleanly; a hand-edited one is left alone and reported
|
|
109
|
+
* as a conflict rather than overwritten. */
|
|
110
|
+
export declare function upgradePackage(agentName: string, packageName: string, options?: FetchOptions & {
|
|
111
|
+
spec?: string;
|
|
112
|
+
}): Promise<{
|
|
113
|
+
files: UpgradeFileResult[];
|
|
114
|
+
}>;
|
|
115
|
+
/** Removes an installed package's tool and skill files, and its actauth
|
|
116
|
+
* rules. A file whose content no longer matches the hash recorded at
|
|
117
|
+
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
118
|
+
* unless `force` is passed — a concrete implementation of PACKAGES.md's
|
|
119
|
+
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
120
|
+
* content or refetching the package to diff. Does not patch
|
|
121
|
+
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
122
|
+
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
123
|
+
* comment already applies; a stale import surfaces as a clear build
|
|
124
|
+
* error, not a silent break. */
|
|
125
|
+
export declare function removePackage(agentName: string, packageName: string, force?: boolean): {
|
|
126
|
+
removed: string[];
|
|
127
|
+
refused: string[];
|
|
128
|
+
};
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// Backs `loopengine add-package|upgrade-package|remove-package` (see
|
|
2
|
+
// bin/cli.ts) — the install/upgrade/remove mechanics PACKAGES.md
|
|
3
|
+
// specifies for a "loopengine package": a bundle of tool files, skill
|
|
4
|
+
// directories, and actauth rules, installed by *copying* files into an
|
|
5
|
+
// agent's own tree (never an npm import) so a package's code is exactly
|
|
6
|
+
// as reviewable/hand-editable as anything the Admin UI's HTTP tool
|
|
7
|
+
// builder already generates (see web/http-tool-admin.ts's own header
|
|
8
|
+
// comment on `generateToolCode` for that same "real code, not an opaque
|
|
9
|
+
// import" reasoning).
|
|
10
|
+
//
|
|
11
|
+
// Deliberately reimplements (rather than imports) the two techniques
|
|
12
|
+
// create-loopengine's own `upgrade` command already has —
|
|
13
|
+
// `fetchPublishedTemplateDir`'s npm-pack-and-extract, and
|
|
14
|
+
// `threeWayMerge`'s `git merge-file --diff3` — since `loopengine` and
|
|
15
|
+
// `create-loopengine` are separate published packages and a runtime
|
|
16
|
+
// depending on a scaffolding tool (or vice versa) isn't a dependency
|
|
17
|
+
// direction worth introducing to share ~30 lines (see PACKAGES.md's
|
|
18
|
+
// "Upgrading" section).
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { basename, join } from 'node:path';
|
|
22
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { parse as parseYaml } from 'yaml';
|
|
25
|
+
import semver from 'semver';
|
|
26
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
27
|
+
import { addToolToIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
28
|
+
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
29
|
+
export class PackageManifestError extends Error {
|
|
30
|
+
}
|
|
31
|
+
export class PackageVersionError extends Error {
|
|
32
|
+
}
|
|
33
|
+
export class PackageCollisionError extends Error {
|
|
34
|
+
}
|
|
35
|
+
export class PackageNotInstalledError extends Error {
|
|
36
|
+
}
|
|
37
|
+
export class PackageAlreadyInstalledError extends Error {
|
|
38
|
+
}
|
|
39
|
+
// Same character sets tool names / skill ids are already validated
|
|
40
|
+
// against elsewhere (web/http-tool-admin.ts's TOOL_NAME_PATTERN,
|
|
41
|
+
// web/skills-admin.ts's SKILL_ID_PATTERN) — re-checked here because a
|
|
42
|
+
// name derived from an untrusted package's own manifest becomes a path
|
|
43
|
+
// segment (agents/<agent>/tools/<name>.ts, .../skills/<id>/): without
|
|
44
|
+
// this, a package declaring a skill dir literally named ".." would have
|
|
45
|
+
// `basename('..')` hand back `'..'` unchanged (path.basename does not
|
|
46
|
+
// strip it), and the later `cpSync` would land one directory up, inside
|
|
47
|
+
// the agent's own root instead of its skills/ folder.
|
|
48
|
+
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
49
|
+
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
50
|
+
function provenancePath(agentName) {
|
|
51
|
+
return join(agentDir(agentName), '.loopengine-packages.json');
|
|
52
|
+
}
|
|
53
|
+
function readProvenance(agentName) {
|
|
54
|
+
const path = provenancePath(agentName);
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return {};
|
|
57
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
function writeProvenance(agentName, data) {
|
|
60
|
+
writeFileSync(provenancePath(agentName), JSON.stringify(data, null, 2) + '\n');
|
|
61
|
+
}
|
|
62
|
+
function sha256(content) {
|
|
63
|
+
return createHash('sha256').update(content).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
// ---- Fetch ----
|
|
66
|
+
/** `npm pack <spec>` + tar extract — generalized from
|
|
67
|
+
* create-loopengine's own `fetchPublishedTemplateDir` (that function
|
|
68
|
+
* only ever fetches the hardcoded `create-loopengine@<version>`; this
|
|
69
|
+
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
70
|
+
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
71
|
+
* — the last of which is what this module's own tests point at, so no
|
|
72
|
+
* real network call is needed there; see PACKAGES.md's "Publishing a
|
|
73
|
+
* package" section). */
|
|
74
|
+
export function fetchPackageDir(spec) {
|
|
75
|
+
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-package-'));
|
|
76
|
+
execFileSync('npm', ['pack', spec, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
77
|
+
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
78
|
+
if (!tarball) {
|
|
79
|
+
throw new Error(`Could not fetch '${spec}' — check the package exists and is reachable (registry auth / git credentials / local path).`);
|
|
80
|
+
}
|
|
81
|
+
// Absolute path, not the bare filename readdirSync returns — same
|
|
82
|
+
// "tar resolves a relative first argument against the calling
|
|
83
|
+
// process's own cwd, not workDir" gotcha fetchPublishedTemplateDir's
|
|
84
|
+
// own comment already documents.
|
|
85
|
+
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
86
|
+
return join(workDir, 'package');
|
|
87
|
+
}
|
|
88
|
+
function readManifest(packageDir) {
|
|
89
|
+
const manifestPath = join(packageDir, 'loopengine.package.json');
|
|
90
|
+
if (!existsSync(manifestPath)) {
|
|
91
|
+
throw new PackageManifestError(`${packageDir} has no loopengine.package.json — not a valid loopengine package.`);
|
|
92
|
+
}
|
|
93
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
94
|
+
if (!manifest.name || !manifest.version || !manifest.loopengineVersion) {
|
|
95
|
+
throw new PackageManifestError('loopengine.package.json must have "name", "version", and "loopengineVersion".');
|
|
96
|
+
}
|
|
97
|
+
return manifest;
|
|
98
|
+
}
|
|
99
|
+
// The installing project's own dependencies.loopengine is itself a
|
|
100
|
+
// range (e.g. "^0.1.10"), not a concrete installed version — checking
|
|
101
|
+
// the *floor* of that range against the manifest's required range is
|
|
102
|
+
// the conservative choice: if even the lowest version the range could
|
|
103
|
+
// resolve to wouldn't satisfy the package, refuse now rather than risk
|
|
104
|
+
// the exact silent-failure gap the Parallel-safe checkbox already hit
|
|
105
|
+
// (a feature that imports an export an *actually installed* older
|
|
106
|
+
// version doesn't have yet, with no error, just quietly not working).
|
|
107
|
+
function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
108
|
+
let installedRange = installedRangeOverride;
|
|
109
|
+
if (!installedRange) {
|
|
110
|
+
// process.cwd(), not core/agent-registry.ts's own projectDir() —
|
|
111
|
+
// that function resolves relative to *this compiled file's own
|
|
112
|
+
// location*, correct only when agents/ is compiled alongside
|
|
113
|
+
// dist/core/, which doesn't hold for a real scaffolded project
|
|
114
|
+
// (loopengine lives in node_modules/loopengine/dist/, but the
|
|
115
|
+
// project's own package.json is at the project root). Every other
|
|
116
|
+
// cwd-relative path in this CLI (bin/cli.ts's requireAdapterFile,
|
|
117
|
+
// core/gateway-tools.ts's own agentsRootDir, which agentDir() above
|
|
118
|
+
// already relies on throughout this file) already resolves this way
|
|
119
|
+
// — confirmed live: a real `npm pack file:...` install against this
|
|
120
|
+
// repo's own dist/bin/cli.js failed against projectDir() here before
|
|
121
|
+
// this fix, for exactly this reason.
|
|
122
|
+
const ownPkgPath = join(process.cwd(), 'package.json');
|
|
123
|
+
const ownPkg = JSON.parse(readFileSync(ownPkgPath, 'utf8'));
|
|
124
|
+
// A checkout of loopengine's own source (this repo, including its
|
|
125
|
+
// own test suite) has no "loopengine" entry to check against — it
|
|
126
|
+
// can't depend on itself. Same "detected by package.json's own
|
|
127
|
+
// name, not a flag to remember" distinction bin/cli.ts's own
|
|
128
|
+
// configImportSpecifier already makes for exactly this
|
|
129
|
+
// repo-vs-real-consumer-project case.
|
|
130
|
+
if (ownPkg.name === 'loopengine')
|
|
131
|
+
return;
|
|
132
|
+
installedRange = ownPkg.dependencies?.loopengine;
|
|
133
|
+
if (!installedRange) {
|
|
134
|
+
throw new PackageVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// A non-registry dependency specifier (file:, git:, workspace:, ...) —
|
|
138
|
+
// common for local development against an unpublished loopengine
|
|
139
|
+
// build — isn't a semver range at all, so minVersion throws rather
|
|
140
|
+
// than returning null; nothing meaningful to compare against, so skip
|
|
141
|
+
// the check rather than crash the whole command on it (same "nothing
|
|
142
|
+
// to compare, nothing to refuse" reasoning the this-repo-itself skip
|
|
143
|
+
// above already uses).
|
|
144
|
+
let floor;
|
|
145
|
+
try {
|
|
146
|
+
floor = semver.minVersion(installedRange);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!floor || !semver.satisfies(floor, manifest.loopengineVersion)) {
|
|
152
|
+
throw new PackageVersionError(`Package '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function parseActauthRules(packageDir, manifest) {
|
|
156
|
+
if (!manifest.actauth)
|
|
157
|
+
return [];
|
|
158
|
+
const rulesPath = join(packageDir, manifest.actauth);
|
|
159
|
+
if (!existsSync(rulesPath)) {
|
|
160
|
+
throw new PackageManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the package.`);
|
|
161
|
+
}
|
|
162
|
+
const parsed = parseYaml(readFileSync(rulesPath, 'utf8'));
|
|
163
|
+
if (!Array.isArray(parsed)) {
|
|
164
|
+
throw new PackageManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
// ---- Install ----
|
|
169
|
+
/** Installs `spec` (anything `npm pack` accepts) for `agentName`:
|
|
170
|
+
* writes each manifest-listed tool file and patches tools/index.ts
|
|
171
|
+
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
172
|
+
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
173
|
+
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
174
|
+
* frontmatter from scratch and can't carry a package's own
|
|
175
|
+
* frontmatter/assets), and appends each actauth rule (addActauthRule).
|
|
176
|
+
*
|
|
177
|
+
* Deliberately does *not* attempt a live in-memory registry splice the
|
|
178
|
+
* way adapters/http.ts's own handleHttpToolPost does for an
|
|
179
|
+
* admin-created tool — that works there because the HTTP request
|
|
180
|
+
* creating the tool runs *inside the same process* as the already-running
|
|
181
|
+
* server, so updateAgent's mutation is visible to every later request in
|
|
182
|
+
* that same process immediately. `add-package` is a separate, one-off
|
|
183
|
+
* CLI process with no connection to whatever server might be running
|
|
184
|
+
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
185
|
+
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
186
|
+
* vanish the moment the process exits, having done nothing to the real
|
|
187
|
+
* running server. (Confirmed live: this used to import
|
|
188
|
+
* core/agent-registry.js, whose own discoverAgents does a top-level
|
|
189
|
+
* directory scan relative to *its own compiled file's location* —
|
|
190
|
+
* inside node_modules/loopengine when this runs there, not the
|
|
191
|
+
* consuming project's real agents/ at all, throwing ENOENT immediately.)
|
|
192
|
+
* Same reason add-agent/add-subagent above don't attempt this either —
|
|
193
|
+
* a new tool becomes active the same way a new agent does: the next
|
|
194
|
+
* request under `serve`, or automatically under `npx loopengine dev`'s
|
|
195
|
+
* file watcher once tools/index.ts's own edit is picked up.
|
|
196
|
+
*
|
|
197
|
+
* All-or-nothing: every collision check below runs, and the whole
|
|
198
|
+
* install is refused, before a single file is written. */
|
|
199
|
+
export async function installPackage(agentName, spec, options = {}) {
|
|
200
|
+
const fetch = options.fetchPackageDir ?? fetchPackageDir;
|
|
201
|
+
const packageDir = fetch(spec);
|
|
202
|
+
const manifest = readManifest(packageDir);
|
|
203
|
+
checkLoopengineVersion(manifest, options.installedLoopengineRange);
|
|
204
|
+
const provenance = readProvenance(agentName);
|
|
205
|
+
if (provenance[manifest.name]) {
|
|
206
|
+
throw new PackageAlreadyInstalledError(`Package '${manifest.name}' is already installed for '${agentName}' — use upgrade-package instead.`);
|
|
207
|
+
}
|
|
208
|
+
const toolFiles = manifest.tools ?? [];
|
|
209
|
+
const skillDirs = manifest.skills ?? [];
|
|
210
|
+
const rules = parseActauthRules(packageDir, manifest);
|
|
211
|
+
const toolsDir = join(agentDir(agentName), 'tools');
|
|
212
|
+
const skillsDirPath = join(agentDir(agentName), 'skills');
|
|
213
|
+
// Collision checks — refuse the whole install before writing anything,
|
|
214
|
+
// same "refuse rather than guess" rule HttpToolExistsError/
|
|
215
|
+
// HttpToolIndexShapeError already enforce for a single admin-created
|
|
216
|
+
// tool, just applied package-wide.
|
|
217
|
+
const toolNames = [];
|
|
218
|
+
for (const toolFile of toolFiles) {
|
|
219
|
+
const toolName = basename(toolFile, '.ts');
|
|
220
|
+
if (!TOOL_NAME_PATTERN.test(toolName)) {
|
|
221
|
+
throw new PackageManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
|
|
222
|
+
}
|
|
223
|
+
toolNames.push(toolName);
|
|
224
|
+
if (existsSync(join(toolsDir, `${toolName}.ts`))) {
|
|
225
|
+
throw new PackageCollisionError(`agents/${agentName}/tools/${toolName}.ts already exists.`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const skillIds = [];
|
|
229
|
+
for (const skillDir of skillDirs) {
|
|
230
|
+
const skillId = basename(skillDir);
|
|
231
|
+
if (!SKILL_ID_PATTERN.test(skillId)) {
|
|
232
|
+
throw new PackageManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
|
|
233
|
+
}
|
|
234
|
+
skillIds.push(skillId);
|
|
235
|
+
if (existsSync(join(skillsDirPath, skillId))) {
|
|
236
|
+
throw new PackageCollisionError(`agents/${agentName}/skills/${skillId}/ already exists.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
|
|
240
|
+
for (const rule of rules) {
|
|
241
|
+
if (existingRuleNames.has(rule.name)) {
|
|
242
|
+
throw new PackageCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const contentHashes = {};
|
|
246
|
+
// Write tool files and patch tools/index.ts — pure filesystem
|
|
247
|
+
// operations, no dynamic import and no registry interaction (see this
|
|
248
|
+
// function's own doc comment for why: a CLI process can't live-splice
|
|
249
|
+
// into a separate, already-running server).
|
|
250
|
+
if (toolNames.length > 0) {
|
|
251
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
252
|
+
const indexPath = join(toolsDir, 'index.ts');
|
|
253
|
+
for (const toolFile of toolFiles) {
|
|
254
|
+
const toolName = basename(toolFile, '.ts');
|
|
255
|
+
const code = readFileSync(join(packageDir, toolFile), 'utf8');
|
|
256
|
+
const destPath = join(toolsDir, `${toolName}.ts`);
|
|
257
|
+
writeFileSync(destPath, code);
|
|
258
|
+
contentHashes[`tools/${toolName}.ts`] = sha256(code);
|
|
259
|
+
const exportName = toCamelCase(toolName);
|
|
260
|
+
if (existsSync(indexPath)) {
|
|
261
|
+
addToolToIndex(indexPath, toolName, exportName);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${exportName} } from './${toolName}.js'\n\nexport const tools: ToolDefinition[] = [${exportName}]\n`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Copy skill directories verbatim.
|
|
269
|
+
for (const skillDir of skillDirs) {
|
|
270
|
+
const skillId = basename(skillDir);
|
|
271
|
+
const srcPath = join(packageDir, skillDir);
|
|
272
|
+
const destPath = join(skillsDirPath, skillId);
|
|
273
|
+
mkdirSync(skillsDirPath, { recursive: true });
|
|
274
|
+
cpSync(srcPath, destPath, { recursive: true });
|
|
275
|
+
const skillMdPath = join(destPath, 'SKILL.md');
|
|
276
|
+
if (existsSync(skillMdPath)) {
|
|
277
|
+
contentHashes[`skills/${skillId}/SKILL.md`] = sha256(readFileSync(skillMdPath, 'utf8'));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Append actauth rules.
|
|
281
|
+
for (const rule of rules) {
|
|
282
|
+
addActauthRule(agentName, rule);
|
|
283
|
+
}
|
|
284
|
+
provenance[manifest.name] = {
|
|
285
|
+
version: manifest.version,
|
|
286
|
+
tools: toolNames,
|
|
287
|
+
skills: skillIds,
|
|
288
|
+
actauthRules: rules.map((r) => r.name),
|
|
289
|
+
env: manifest.env ?? [],
|
|
290
|
+
contentHashes,
|
|
291
|
+
};
|
|
292
|
+
writeProvenance(agentName, provenance);
|
|
293
|
+
return { installed: [...toolNames.map((n) => `tools/${n}.ts`), ...skillIds.map((id) => `skills/${id}/`), ...rules.map((r) => `actauth:${r.name}`)] };
|
|
294
|
+
}
|
|
295
|
+
// ---- Upgrade ----
|
|
296
|
+
// git merge-file's own exit code *is* its conflict count (0 = clean),
|
|
297
|
+
// not a pass/fail signal — same distinction create-loopengine's own
|
|
298
|
+
// threeWayMerge already documents. Operates on a disposable scratch
|
|
299
|
+
// copy, never the real project file directly.
|
|
300
|
+
function mergeFile(mine, base, theirs) {
|
|
301
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-package-merge-'));
|
|
302
|
+
const minePath = join(scratchDir, 'mine');
|
|
303
|
+
const basePath = join(scratchDir, 'base');
|
|
304
|
+
const theirsPath = join(scratchDir, 'theirs');
|
|
305
|
+
writeFileSync(minePath, mine);
|
|
306
|
+
writeFileSync(basePath, base);
|
|
307
|
+
writeFileSync(theirsPath, theirs);
|
|
308
|
+
let conflicted = false;
|
|
309
|
+
try {
|
|
310
|
+
execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], { stdio: 'pipe' });
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
|
|
314
|
+
conflicted = true;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const merged = readFileSync(minePath, 'utf8');
|
|
321
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
322
|
+
return { merged, conflicted };
|
|
323
|
+
}
|
|
324
|
+
/** Upgrades an already-installed package to whatever `spec` (defaulting
|
|
325
|
+
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
326
|
+
* files get a real three-way merge (mine = current file, possibly
|
|
327
|
+
* hand-edited since install; base = the version recorded at
|
|
328
|
+
* install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
|
|
329
|
+
* newly fetched) — identical technique to `create-loopengine upgrade`,
|
|
330
|
+
* just applied to package-managed files instead of template files.
|
|
331
|
+
* actauth rules upgrade per-rule instead: the target actauth.yml holds
|
|
332
|
+
* rules from other packages and hand-written ones too, so there's no
|
|
333
|
+
* coherent "whole file" base/theirs to merge — a rule unchanged since
|
|
334
|
+
* install updates cleanly; a hand-edited one is left alone and reported
|
|
335
|
+
* as a conflict rather than overwritten. */
|
|
336
|
+
export async function upgradePackage(agentName, packageName, options = {}) {
|
|
337
|
+
const fetch = options.fetchPackageDir ?? fetchPackageDir;
|
|
338
|
+
const provenance = readProvenance(agentName);
|
|
339
|
+
const record = provenance[packageName];
|
|
340
|
+
if (!record) {
|
|
341
|
+
throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
|
|
342
|
+
}
|
|
343
|
+
const spec = options.spec ?? packageName;
|
|
344
|
+
const oldDir = fetch(`${spec}@${record.version}`);
|
|
345
|
+
const newDir = fetch(spec);
|
|
346
|
+
const oldManifest = readManifest(oldDir);
|
|
347
|
+
const newManifest = readManifest(newDir);
|
|
348
|
+
checkLoopengineVersion(newManifest, options.installedLoopengineRange);
|
|
349
|
+
const results = [];
|
|
350
|
+
const newContentHashes = { ...record.contentHashes };
|
|
351
|
+
for (const toolName of record.tools) {
|
|
352
|
+
const relPath = `tools/${toolName}.ts`;
|
|
353
|
+
const oldFile = (oldManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
|
|
354
|
+
const newFile = (newManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
|
|
355
|
+
if (!oldFile || !newFile) {
|
|
356
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const base = readFileSync(join(oldDir, oldFile), 'utf8');
|
|
360
|
+
const theirs = readFileSync(join(newDir, newFile), 'utf8');
|
|
361
|
+
if (base === theirs) {
|
|
362
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const minePath = join(agentDir(agentName), relPath);
|
|
366
|
+
const { merged, conflicted } = mergeFile(readFileSync(minePath, 'utf8'), base, theirs);
|
|
367
|
+
writeFileSync(minePath, merged);
|
|
368
|
+
if (!conflicted)
|
|
369
|
+
newContentHashes[relPath] = sha256(merged);
|
|
370
|
+
results.push({ path: relPath, status: conflicted ? 'conflict' : 'updated' });
|
|
371
|
+
}
|
|
372
|
+
for (const skillId of record.skills) {
|
|
373
|
+
const relPath = `skills/${skillId}/SKILL.md`;
|
|
374
|
+
const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === skillId);
|
|
375
|
+
const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === skillId);
|
|
376
|
+
const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
|
|
377
|
+
const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
|
|
378
|
+
if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
|
|
379
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const base = readFileSync(oldFile, 'utf8');
|
|
383
|
+
const theirs = readFileSync(newFile, 'utf8');
|
|
384
|
+
if (base === theirs) {
|
|
385
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const minePath = join(agentDir(agentName), relPath);
|
|
389
|
+
const { merged, conflicted } = mergeFile(readFileSync(minePath, 'utf8'), base, theirs);
|
|
390
|
+
writeFileSync(minePath, merged);
|
|
391
|
+
if (!conflicted)
|
|
392
|
+
newContentHashes[relPath] = sha256(merged);
|
|
393
|
+
results.push({ path: relPath, status: conflicted ? 'conflict' : 'updated' });
|
|
394
|
+
}
|
|
395
|
+
const oldRules = parseActauthRules(oldDir, oldManifest);
|
|
396
|
+
const newRules = parseActauthRules(newDir, newManifest);
|
|
397
|
+
const currentRules = new Map(readActauthConfig(agentName).rules.map((r) => [r.name, r]));
|
|
398
|
+
for (const ruleName of record.actauthRules) {
|
|
399
|
+
const oldRule = oldRules.find((r) => r.name === ruleName);
|
|
400
|
+
const newRule = newRules.find((r) => r.name === ruleName);
|
|
401
|
+
const current = currentRules.get(ruleName);
|
|
402
|
+
const path = `actauth:${ruleName}`;
|
|
403
|
+
if (!oldRule || !newRule || !current) {
|
|
404
|
+
results.push({ path, status: 'unchanged' });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const unchangedSinceInstall = current.scope === oldRule.scope && current.tool === oldRule.tool && current.decision === oldRule.decision;
|
|
408
|
+
if (!unchangedSinceInstall) {
|
|
409
|
+
results.push({ path, status: 'conflict' });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (oldRule.scope === newRule.scope && oldRule.tool === newRule.tool && oldRule.decision === newRule.decision) {
|
|
413
|
+
results.push({ path, status: 'unchanged' });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
417
|
+
results.push({ path, status: 'updated' });
|
|
418
|
+
}
|
|
419
|
+
provenance[packageName] = { ...record, version: newManifest.version, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
420
|
+
writeProvenance(agentName, provenance);
|
|
421
|
+
return { files: results };
|
|
422
|
+
}
|
|
423
|
+
// ---- Remove ----
|
|
424
|
+
function isDirty(path, recordedHash) {
|
|
425
|
+
if (!existsSync(path))
|
|
426
|
+
return false;
|
|
427
|
+
if (!recordedHash)
|
|
428
|
+
return true;
|
|
429
|
+
return sha256(readFileSync(path, 'utf8')) !== recordedHash;
|
|
430
|
+
}
|
|
431
|
+
/** Removes an installed package's tool and skill files, and its actauth
|
|
432
|
+
* rules. A file whose content no longer matches the hash recorded at
|
|
433
|
+
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
434
|
+
* unless `force` is passed — a concrete implementation of PACKAGES.md's
|
|
435
|
+
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
436
|
+
* content or refetching the package to diff. Does not patch
|
|
437
|
+
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
438
|
+
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
439
|
+
* comment already applies; a stale import surfaces as a clear build
|
|
440
|
+
* error, not a silent break. */
|
|
441
|
+
export function removePackage(agentName, packageName, force = false) {
|
|
442
|
+
const provenance = readProvenance(agentName);
|
|
443
|
+
const record = provenance[packageName];
|
|
444
|
+
if (!record) {
|
|
445
|
+
throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
|
|
446
|
+
}
|
|
447
|
+
const removed = [];
|
|
448
|
+
const refused = [];
|
|
449
|
+
const remainingTools = [];
|
|
450
|
+
const remainingSkills = [];
|
|
451
|
+
for (const toolName of record.tools) {
|
|
452
|
+
const relPath = `tools/${toolName}.ts`;
|
|
453
|
+
const fullPath = join(agentDir(agentName), relPath);
|
|
454
|
+
if (!force && isDirty(fullPath, record.contentHashes[relPath])) {
|
|
455
|
+
refused.push(relPath);
|
|
456
|
+
remainingTools.push(toolName);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
rmSync(fullPath, { force: true });
|
|
460
|
+
removed.push(relPath);
|
|
461
|
+
}
|
|
462
|
+
for (const skillId of record.skills) {
|
|
463
|
+
const relPath = `skills/${skillId}/SKILL.md`;
|
|
464
|
+
const fullPath = join(agentDir(agentName), relPath);
|
|
465
|
+
if (!force && isDirty(fullPath, record.contentHashes[relPath])) {
|
|
466
|
+
refused.push(relPath);
|
|
467
|
+
remainingSkills.push(skillId);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
rmSync(join(agentDir(agentName), 'skills', skillId), { recursive: true, force: true });
|
|
471
|
+
removed.push(`skills/${skillId}/`);
|
|
472
|
+
}
|
|
473
|
+
for (const ruleName of record.actauthRules) {
|
|
474
|
+
try {
|
|
475
|
+
removeActauthRule(agentName, ruleName);
|
|
476
|
+
removed.push(`actauth:${ruleName}`);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Already gone (hand-removed since install) — not a failure.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (refused.length === 0) {
|
|
483
|
+
delete provenance[packageName];
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
provenance[packageName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
|
|
487
|
+
}
|
|
488
|
+
writeProvenance(agentName, provenance);
|
|
489
|
+
return { removed, refused };
|
|
490
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundE
|
|
|
43
43
|
export type { SkillContent } from './web/skills-admin.js';
|
|
44
44
|
export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
|
|
45
45
|
export type { SkillgardenCatalogEntry, SkillgardenCatalogDetail } from './web/skillgarden-admin.js';
|
|
46
|
+
export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin.js';
|
|
47
|
+
export type { DeclaredEnvVar } from './web/env-admin.js';
|
|
46
48
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
47
49
|
export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
|
|
48
50
|
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
|
44
44
|
export { playgroundHtml } from './web/playground.js';
|
|
45
45
|
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
46
46
|
export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
|
|
47
|
+
export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin.js';
|
|
47
48
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
48
49
|
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
49
50
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
@@ -425,6 +425,8 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
425
425
|
// Same idea, for the Actauth tab's own separately-fetched GET
|
|
426
426
|
// .../actauth (see renderActauthTabPlaceholder's own doc comment).
|
|
427
427
|
var actauthLoadedFor = null;
|
|
428
|
+
// Same idea again, for the Environment tab's own GET .../env.
|
|
429
|
+
var envLoadedFor = null;
|
|
428
430
|
// The full /agents/:name/config response the currently-open agent was
|
|
429
431
|
// last rendered from — kept around so refreshActauthDependentPanels/
|
|
430
432
|
// refreshSkillsDependentPanels below can re-fetch and re-render just
|
|
@@ -1128,6 +1130,95 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1128
1130
|
});
|
|
1129
1131
|
}
|
|
1130
1132
|
|
|
1133
|
+
// ---- Environment tab: every env var a package (see PACKAGES.md,
|
|
1134
|
+
// bin/package-manager.ts) declared it needs for this agent, across
|
|
1135
|
+
// every package installed — status only, a value is never shown once
|
|
1136
|
+
// set, matching env-admin.ts's own never-echo-a-secret rule. Lazily
|
|
1137
|
+
// loaded the same way Actauth is, for the same reason (avoid a
|
|
1138
|
+
// redundant fetch on every agent switch for a tab that isn't always
|
|
1139
|
+
// opened). ----
|
|
1140
|
+
|
|
1141
|
+
function renderEnvTabPlaceholder() {
|
|
1142
|
+
return '<section id="envSection"><div id="envContent"><p class="hint">Loading…</p></div></section>';
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function renderEnvRow(v) {
|
|
1146
|
+
return '<tr>' +
|
|
1147
|
+
'<td><code>' + escapeHtml(v.name) + '</code></td>' +
|
|
1148
|
+
'<td>' + escapeHtml(v.description || '') + '</td>' +
|
|
1149
|
+
'<td class="hint">' + escapeHtml(v.packageName) + '</td>' +
|
|
1150
|
+
'<td>' + (v.set ? '<span class="hint">set</span>' : '<span class="error">not set</span>') + '</td>' +
|
|
1151
|
+
'<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
|
|
1152
|
+
'<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>' +
|
|
1153
|
+
'<button type="submit">Save</button>' +
|
|
1154
|
+
'</form></td>' +
|
|
1155
|
+
'</tr>';
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function renderEnvConfigHtml(vars) {
|
|
1159
|
+
if (!vars.length) return '<p class="hint">No installed package has declared any environment variables for this agent yet.</p>';
|
|
1160
|
+
var rows = vars.map(renderEnvRow).join('');
|
|
1161
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Package</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function envContentEl() {
|
|
1165
|
+
return detail.querySelector('#envContent');
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function wireEnvHandlers(name) {
|
|
1169
|
+
var content = envContentEl();
|
|
1170
|
+
if (!content) return;
|
|
1171
|
+
var forms = content.querySelectorAll('.env-var-form');
|
|
1172
|
+
for (var i = 0; i < forms.length; i++) {
|
|
1173
|
+
forms[i].addEventListener('submit', function (ev) {
|
|
1174
|
+
ev.preventDefault();
|
|
1175
|
+
var form = ev.currentTarget;
|
|
1176
|
+
var varName = form.getAttribute('data-name');
|
|
1177
|
+
var value = new FormData(form).get('value');
|
|
1178
|
+
var submitBtn = form.querySelector('button[type="submit"]');
|
|
1179
|
+
submitBtn.disabled = true;
|
|
1180
|
+
fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName), {
|
|
1181
|
+
method: 'PUT',
|
|
1182
|
+
headers: { 'content-type': 'application/json' },
|
|
1183
|
+
body: JSON.stringify({ value: value }),
|
|
1184
|
+
})
|
|
1185
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1186
|
+
.then(function (result) {
|
|
1187
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1188
|
+
loadEnvTab(name);
|
|
1189
|
+
})
|
|
1190
|
+
.catch(function (err) {
|
|
1191
|
+
alert('Could not set value: ' + err.message);
|
|
1192
|
+
submitBtn.disabled = false;
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function applyEnvConfig(name, vars) {
|
|
1199
|
+
var content = envContentEl();
|
|
1200
|
+
if (!content) return;
|
|
1201
|
+
content.innerHTML = renderEnvConfigHtml(vars);
|
|
1202
|
+
wireEnvHandlers(name);
|
|
1203
|
+
envLoadedFor = name;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function loadEnvTab(name) {
|
|
1207
|
+
var content = envContentEl();
|
|
1208
|
+
if (!content) return;
|
|
1209
|
+
content.innerHTML = '<p class="hint">Loading…</p>';
|
|
1210
|
+
fetch('/agents/' + encodeURIComponent(name) + '/env')
|
|
1211
|
+
.then(function (r) { return r.json(); })
|
|
1212
|
+
.then(function (vars) {
|
|
1213
|
+
if (currentName !== name) return;
|
|
1214
|
+
applyEnvConfig(name, vars);
|
|
1215
|
+
})
|
|
1216
|
+
.catch(function (err) {
|
|
1217
|
+
if (currentName !== name) return;
|
|
1218
|
+
content.innerHTML = '<p class="error">Could not load environment variables: ' + escapeHtml(err.message) + '</p>';
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1131
1222
|
// ---- Skills tab: editable — add/edit/delete SKILL.md files via
|
|
1132
1223
|
// skills-admin.ts, restricted (see that file's own doc comment) to
|
|
1133
1224
|
// flat, non-nested skill ids. Unlike the Tools tab's Gateway Tools
|
|
@@ -2088,6 +2179,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2088
2179
|
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
2089
2180
|
loadActauthTab(currentName);
|
|
2090
2181
|
}
|
|
2182
|
+
if (tab === 'env' && envLoadedFor !== currentName) {
|
|
2183
|
+
loadEnvTab(currentName);
|
|
2184
|
+
}
|
|
2091
2185
|
}
|
|
2092
2186
|
|
|
2093
2187
|
// Refreshes just Overview's own summary — used after anything that
|
|
@@ -2163,11 +2257,13 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2163
2257
|
'<button class="tab" data-tab="skills">Skills</button>' +
|
|
2164
2258
|
'<button class="tab" data-tab="tools">Tools</button>' +
|
|
2165
2259
|
'<button class="tab" data-tab="actauth">ActAuth</button>' +
|
|
2260
|
+
'<button class="tab" data-tab="env">Environment</button>' +
|
|
2166
2261
|
'</div>' +
|
|
2167
2262
|
'<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
|
|
2168
2263
|
'<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
|
|
2169
2264
|
'<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
|
|
2170
|
-
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>'
|
|
2265
|
+
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
|
|
2266
|
+
'<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>';
|
|
2171
2267
|
|
|
2172
2268
|
var buttons = detail.querySelectorAll('.tabs button');
|
|
2173
2269
|
for (var i = 0; i < buttons.length; i++) {
|
|
@@ -2191,6 +2287,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2191
2287
|
currentName = name;
|
|
2192
2288
|
gatewayLoadedFor = null;
|
|
2193
2289
|
actauthLoadedFor = null;
|
|
2290
|
+
envLoadedFor = null;
|
|
2194
2291
|
var items = agentList.querySelectorAll('li');
|
|
2195
2292
|
for (var i = 0; i < items.length; i++) {
|
|
2196
2293
|
items[i].classList.toggle('active', items[i].dataset.name === name);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare class EnvVarNameError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface DeclaredEnvVar {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
secret: boolean;
|
|
7
|
+
packageName: string;
|
|
8
|
+
set: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Every env var any package installed for `agentName` declared it
|
|
11
|
+
* needs, deduplicated by name — PACKAGES.md's own open question on
|
|
12
|
+
* cross-package name collisions applies here too (one .env per
|
|
13
|
+
* *project*, not per agent, so two unrelated packages declaring the
|
|
14
|
+
* same name can't actually be told apart; the first one seen wins the
|
|
15
|
+
* description shown). `set` is read live off `process.env`, not cached,
|
|
16
|
+
* so it reflects whatever the last `setEnvVar` call — or a plain
|
|
17
|
+
* restart picking up `.env` — actually did. */
|
|
18
|
+
export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
|
|
19
|
+
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
20
|
+
* every other line (comments, blank lines, unrelated keys) — and
|
|
21
|
+
* applies it to *this* running process immediately via `process.env`,
|
|
22
|
+
* so a newly-installed package's tools work without a restart. Callers
|
|
23
|
+
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
24
|
+
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
25
|
+
* function itself has no notion of HTTP auth, it just writes. */
|
|
26
|
+
export declare function setEnvVar(name: string, value: string): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Backs the Admin UI's "Environment" section — every env var a package
|
|
2
|
+
// (see PACKAGES.md, bin/package-manager.ts) declared as required,
|
|
3
|
+
// across every package installed for an agent, with set/not-set status
|
|
4
|
+
// only. A value marked `secret` is never echoed back once set — same
|
|
5
|
+
// never-echo-a-secret rule web/http-tool-admin.ts's own `{{ENV_VAR}}`
|
|
6
|
+
// header handling already establishes for a tool's own secrets.
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
10
|
+
export class EnvVarNameError extends Error {
|
|
11
|
+
}
|
|
12
|
+
// Same shape web/http-tool-admin.ts's own ENV_VAR_PATTERN already
|
|
13
|
+
// validates a {{ENV_VAR}} header reference against.
|
|
14
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
15
|
+
function provenancePath(agentName) {
|
|
16
|
+
return join(agentDir(agentName), '.loopengine-packages.json');
|
|
17
|
+
}
|
|
18
|
+
/** Every env var any package installed for `agentName` declared it
|
|
19
|
+
* needs, deduplicated by name — PACKAGES.md's own open question on
|
|
20
|
+
* cross-package name collisions applies here too (one .env per
|
|
21
|
+
* *project*, not per agent, so two unrelated packages declaring the
|
|
22
|
+
* same name can't actually be told apart; the first one seen wins the
|
|
23
|
+
* description shown). `set` is read live off `process.env`, not cached,
|
|
24
|
+
* so it reflects whatever the last `setEnvVar` call — or a plain
|
|
25
|
+
* restart picking up `.env` — actually did. */
|
|
26
|
+
export function listDeclaredEnvVars(agentName) {
|
|
27
|
+
const path = provenancePath(agentName);
|
|
28
|
+
if (!existsSync(path))
|
|
29
|
+
return [];
|
|
30
|
+
const provenance = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const result = [];
|
|
33
|
+
for (const [packageName, record] of Object.entries(provenance)) {
|
|
34
|
+
for (const decl of record.env) {
|
|
35
|
+
if (seen.has(decl.name))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(decl.name);
|
|
38
|
+
result.push({
|
|
39
|
+
name: decl.name,
|
|
40
|
+
description: decl.description,
|
|
41
|
+
secret: decl.secret === true,
|
|
42
|
+
packageName,
|
|
43
|
+
set: process.env[decl.name] !== undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
// process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
|
|
50
|
+
// --env-file-if-exists=.env (Node's own --env-file resolves relative to
|
|
51
|
+
// cwd too) — not core/agent-registry.ts's projectDir(), which resolves
|
|
52
|
+
// relative to *that compiled file's own location* and would point at
|
|
53
|
+
// node_modules/loopengine/dist/ in a real scaffolded project, not the
|
|
54
|
+
// project's own root where .env actually lives.
|
|
55
|
+
function envFilePath() {
|
|
56
|
+
return join(process.cwd(), '.env');
|
|
57
|
+
}
|
|
58
|
+
// Node's own --env-file parser (what bin/cli.ts's runTsx already passes
|
|
59
|
+
// as --env-file-if-exists=.env) double-quotes a value containing
|
|
60
|
+
// whitespace, '#', or a quote character — matched here so a value this
|
|
61
|
+
// function writes reads back identically, not reinterpreted as a
|
|
62
|
+
// comment or truncated at the first space.
|
|
63
|
+
function serializeEnvValue(value) {
|
|
64
|
+
if (!/[\s#"'\\]/.test(value) && value !== '')
|
|
65
|
+
return value;
|
|
66
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
67
|
+
}
|
|
68
|
+
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
69
|
+
* every other line (comments, blank lines, unrelated keys) — and
|
|
70
|
+
* applies it to *this* running process immediately via `process.env`,
|
|
71
|
+
* so a newly-installed package's tools work without a restart. Callers
|
|
72
|
+
* (the PUT route in adapters/http.ts) are responsible for refusing to
|
|
73
|
+
* call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
|
|
74
|
+
* function itself has no notion of HTTP auth, it just writes. */
|
|
75
|
+
export function setEnvVar(name, value) {
|
|
76
|
+
if (!ENV_VAR_NAME_PATTERN.test(name)) {
|
|
77
|
+
throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
|
|
78
|
+
}
|
|
79
|
+
const path = envFilePath();
|
|
80
|
+
const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
|
|
81
|
+
const newLine = `${name}=${serializeEnvValue(value)}`;
|
|
82
|
+
const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
|
|
83
|
+
if (existingIndex === -1) {
|
|
84
|
+
// Drop a single trailing blank line (from the file's own final
|
|
85
|
+
// newline splitting into an empty last element) before appending,
|
|
86
|
+
// so this doesn't accumulate a growing gap of blank lines across
|
|
87
|
+
// repeated calls.
|
|
88
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
89
|
+
lines.pop();
|
|
90
|
+
lines.push(newLine);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
lines[existingIndex] = newLine;
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(path, lines.join('\n') + '\n');
|
|
96
|
+
process.env[name] = value;
|
|
97
|
+
}
|
|
@@ -36,6 +36,8 @@ export interface HttpToolSpec {
|
|
|
36
36
|
* based on HTTP verb alone. */
|
|
37
37
|
safe?: boolean;
|
|
38
38
|
}
|
|
39
|
+
export declare function toCamelCase(snakeCase: string): string;
|
|
40
|
+
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
|
|
39
41
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
40
42
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
41
43
|
* returns the real, callable ToolDefinition object — so the route
|
|
@@ -48,7 +48,7 @@ function tsStringLiteral(value) {
|
|
|
48
48
|
// "lookup_order_status" -> "lookupOrderStatus" — matches every
|
|
49
49
|
// hand-written tool file's own convention (see agents/customer-service/
|
|
50
50
|
// tools/*.ts: issue_refund.ts exports `issueRefund`, etc.).
|
|
51
|
-
function toCamelCase(snakeCase) {
|
|
51
|
+
export function toCamelCase(snakeCase) {
|
|
52
52
|
return snakeCase.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
53
53
|
}
|
|
54
54
|
// Escapes a literal segment for safe interpolation inside a generated
|
|
@@ -185,7 +185,7 @@ ${responseHandling} },
|
|
|
185
185
|
// pattern doesn't recognize, it refuses instead of risking corrupting it
|
|
186
186
|
// — same "refuse rather than guess" discipline agent-file-admin.ts's own
|
|
187
187
|
// doc comment already establishes for index.ts edits.
|
|
188
|
-
function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
188
|
+
export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
189
189
|
const source = readFileSync(toolsIndexPath, 'utf8');
|
|
190
190
|
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
191
191
|
if (!arrayMatch) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"dist/index.d.ts",
|
|
24
24
|
"dist/bin/cli.js",
|
|
25
25
|
"dist/bin/cli.d.ts",
|
|
26
|
+
"dist/bin/package-manager.js",
|
|
27
|
+
"dist/bin/package-manager.d.ts",
|
|
26
28
|
"dist/core/run-agent.js",
|
|
27
29
|
"dist/core/run-agent.d.ts",
|
|
28
30
|
"dist/core/loop-events.js",
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
"ioredis": "^6.0.0",
|
|
84
86
|
"mcpplug": "^0.0.1",
|
|
85
87
|
"openai": "^7.4.0",
|
|
88
|
+
"semver": "^7.8.5",
|
|
86
89
|
"sessionknit": "^0.0.2",
|
|
87
90
|
"skillgarden": "^0.0.9",
|
|
88
91
|
"toollane": "^0.0.1",
|
|
@@ -91,6 +94,7 @@
|
|
|
91
94
|
},
|
|
92
95
|
"devDependencies": {
|
|
93
96
|
"@types/node": "^26.2.0",
|
|
97
|
+
"@types/semver": "^7.8.0",
|
|
94
98
|
"tsx": "^4.19.0",
|
|
95
99
|
"vitest": "^2.1.9"
|
|
96
100
|
}
|