loopengine 0.1.9 → 0.1.11
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 +101 -0
- package/dist/bin/package-manager.d.ts +110 -0
- package/dist/bin/package-manager.js +473 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/web/agents-config-page.js +101 -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 +10 -0
- package/dist/web/http-tool-admin.js +3 -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,85 @@ 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
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
326
|
+
process.exitCode = 1;
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Pulls in whatever <packageName> (or --spec, if the package moved)
|
|
331
|
+
// currently resolves to, three-way-merging each tool/skill file it
|
|
332
|
+
// manages against what changed upstream since install — a hand-edit
|
|
333
|
+
// survives a clean merge; a real conflict leaves <<<<<<< markers to
|
|
334
|
+
// resolve by hand, same as create-loopengine upgrade already does for
|
|
335
|
+
// template files.
|
|
336
|
+
if (command === 'upgrade-package') {
|
|
337
|
+
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
338
|
+
const { value: spec, rest: withoutSpec } = extractFlagValue(withoutAgent, '--spec');
|
|
339
|
+
const [packageName] = withoutSpec;
|
|
340
|
+
if (!packageName || !agent) {
|
|
341
|
+
console.error('Usage: loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const { files } = await upgradePackage(agent, packageName, spec ? { spec } : {});
|
|
347
|
+
for (const f of files)
|
|
348
|
+
console.log(` ${f.status.padEnd(9)}${f.path}`);
|
|
349
|
+
const conflicts = files.filter((f) => f.status === 'conflict');
|
|
350
|
+
console.log(conflicts.length > 0 ? `${conflicts.length} file(s)/rule(s) need resolving by hand.` : 'No conflicts.');
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Removes a package's tool/skill files and actauth rules — refuses a
|
|
359
|
+
// file whose content no longer matches what was recorded at
|
|
360
|
+
// install/last-upgrade (a hand-edit since) unless --force is given.
|
|
361
|
+
if (command === 'remove-package') {
|
|
362
|
+
const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
|
|
363
|
+
const { present: force, rest: withoutForce } = extractFlagPresence(withoutAgent, '--force');
|
|
364
|
+
const [packageName] = withoutForce;
|
|
365
|
+
if (!packageName || !agent) {
|
|
366
|
+
console.error('Usage: loopengine remove-package <packageName> --agent <name> [--force]');
|
|
367
|
+
process.exitCode = 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const { removed, refused } = removePackage(agent, packageName, force);
|
|
372
|
+
for (const p of removed)
|
|
373
|
+
console.log(` removed ${p}`);
|
|
374
|
+
for (const p of refused)
|
|
375
|
+
console.log(` refused ${p} (hand-modified since install — pass --force to remove anyway)`);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
285
383
|
// One-shot: sends one message, prints the reply, exits — exactly
|
|
286
384
|
// adapters/cli.ts's own contract (see its own header comment), not a
|
|
287
385
|
// REPL. `rest` after `<agent>` is forwarded through as-is (--session,
|
|
@@ -362,6 +460,9 @@ async function main() {
|
|
|
362
460
|
}
|
|
363
461
|
console.error('Usage: loopengine add-agent <name>');
|
|
364
462
|
console.error(' loopengine add-subagent <parent> <name>');
|
|
463
|
+
console.error(' loopengine add-package <spec> --agent <name>');
|
|
464
|
+
console.error(' loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
|
|
465
|
+
console.error(' loopengine remove-package <packageName> --agent <name> [--force]');
|
|
365
466
|
console.error(' loopengine run <agent> [--session <id>] "<message>"');
|
|
366
467
|
console.error(' loopengine serve');
|
|
367
468
|
console.error(' loopengine dev');
|
|
@@ -0,0 +1,110 @@
|
|
|
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), appends each actauth rule (addActauthRule), then
|
|
68
|
+
* splices the newly-imported tools into the live agent registry so
|
|
69
|
+
* they're callable immediately, no restart — same updateAgent call
|
|
70
|
+
* adapters/http.ts's handleHttpToolPost already makes. All-or-nothing:
|
|
71
|
+
* every collision check below runs, and the whole install is refused,
|
|
72
|
+
* before a single file is written. */
|
|
73
|
+
export declare function installPackage(agentName: string, spec: string, options?: FetchOptions): Promise<{
|
|
74
|
+
installed: string[];
|
|
75
|
+
}>;
|
|
76
|
+
export interface UpgradeFileResult {
|
|
77
|
+
path: string;
|
|
78
|
+
status: 'updated' | 'unchanged' | 'conflict';
|
|
79
|
+
}
|
|
80
|
+
/** Upgrades an already-installed package to whatever `spec` (defaulting
|
|
81
|
+
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
82
|
+
* files get a real three-way merge (mine = current file, possibly
|
|
83
|
+
* hand-edited since install; base = the version recorded at
|
|
84
|
+
* install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
|
|
85
|
+
* newly fetched) — identical technique to `create-loopengine upgrade`,
|
|
86
|
+
* just applied to package-managed files instead of template files.
|
|
87
|
+
* actauth rules upgrade per-rule instead: the target actauth.yml holds
|
|
88
|
+
* rules from other packages and hand-written ones too, so there's no
|
|
89
|
+
* coherent "whole file" base/theirs to merge — a rule unchanged since
|
|
90
|
+
* install updates cleanly; a hand-edited one is left alone and reported
|
|
91
|
+
* as a conflict rather than overwritten. */
|
|
92
|
+
export declare function upgradePackage(agentName: string, packageName: string, options?: FetchOptions & {
|
|
93
|
+
spec?: string;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
files: UpgradeFileResult[];
|
|
96
|
+
}>;
|
|
97
|
+
/** Removes an installed package's tool and skill files, and its actauth
|
|
98
|
+
* rules. A file whose content no longer matches the hash recorded at
|
|
99
|
+
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
100
|
+
* unless `force` is passed — a concrete implementation of PACKAGES.md's
|
|
101
|
+
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
102
|
+
* content or refetching the package to diff. Does not patch
|
|
103
|
+
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
104
|
+
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
105
|
+
* comment already applies; a stale import surfaces as a clear build
|
|
106
|
+
* error, not a silent break. */
|
|
107
|
+
export declare function removePackage(agentName: string, packageName: string, force?: boolean): {
|
|
108
|
+
removed: string[];
|
|
109
|
+
refused: string[];
|
|
110
|
+
};
|
|
@@ -0,0 +1,473 @@
|
|
|
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 { pathToFileURL } from 'node:url';
|
|
25
|
+
import { parse as parseYaml } from 'yaml';
|
|
26
|
+
import semver from 'semver';
|
|
27
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
28
|
+
import { getEntry, updateAgent } from '../core/agent-registry.js';
|
|
29
|
+
import { loadDefaultTools } from '../core/run-agent.js';
|
|
30
|
+
import { addToolToIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
31
|
+
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
32
|
+
export class PackageManifestError extends Error {
|
|
33
|
+
}
|
|
34
|
+
export class PackageVersionError extends Error {
|
|
35
|
+
}
|
|
36
|
+
export class PackageCollisionError extends Error {
|
|
37
|
+
}
|
|
38
|
+
export class PackageNotInstalledError extends Error {
|
|
39
|
+
}
|
|
40
|
+
export class PackageAlreadyInstalledError extends Error {
|
|
41
|
+
}
|
|
42
|
+
// Same character sets tool names / skill ids are already validated
|
|
43
|
+
// against elsewhere (web/http-tool-admin.ts's TOOL_NAME_PATTERN,
|
|
44
|
+
// web/skills-admin.ts's SKILL_ID_PATTERN) — re-checked here because a
|
|
45
|
+
// name derived from an untrusted package's own manifest becomes a path
|
|
46
|
+
// segment (agents/<agent>/tools/<name>.ts, .../skills/<id>/): without
|
|
47
|
+
// this, a package declaring a skill dir literally named ".." would have
|
|
48
|
+
// `basename('..')` hand back `'..'` unchanged (path.basename does not
|
|
49
|
+
// strip it), and the later `cpSync` would land one directory up, inside
|
|
50
|
+
// the agent's own root instead of its skills/ folder.
|
|
51
|
+
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
52
|
+
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
53
|
+
function provenancePath(agentName) {
|
|
54
|
+
return join(agentDir(agentName), '.loopengine-packages.json');
|
|
55
|
+
}
|
|
56
|
+
function readProvenance(agentName) {
|
|
57
|
+
const path = provenancePath(agentName);
|
|
58
|
+
if (!existsSync(path))
|
|
59
|
+
return {};
|
|
60
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
function writeProvenance(agentName, data) {
|
|
63
|
+
writeFileSync(provenancePath(agentName), JSON.stringify(data, null, 2) + '\n');
|
|
64
|
+
}
|
|
65
|
+
function sha256(content) {
|
|
66
|
+
return createHash('sha256').update(content).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
// ---- Fetch ----
|
|
69
|
+
/** `npm pack <spec>` + tar extract — generalized from
|
|
70
|
+
* create-loopengine's own `fetchPublishedTemplateDir` (that function
|
|
71
|
+
* only ever fetches the hardcoded `create-loopengine@<version>`; this
|
|
72
|
+
* accepts anything `npm pack` does: a public/private registry spec, a
|
|
73
|
+
* git URL (`github:org/repo`, `git+ssh://...`), or a local `file:../path`
|
|
74
|
+
* — the last of which is what this module's own tests point at, so no
|
|
75
|
+
* real network call is needed there; see PACKAGES.md's "Publishing a
|
|
76
|
+
* package" section). */
|
|
77
|
+
export function fetchPackageDir(spec) {
|
|
78
|
+
const workDir = mkdtempSync(join(tmpdir(), 'loopengine-package-'));
|
|
79
|
+
execFileSync('npm', ['pack', spec, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
80
|
+
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
81
|
+
if (!tarball) {
|
|
82
|
+
throw new Error(`Could not fetch '${spec}' — check the package exists and is reachable (registry auth / git credentials / local path).`);
|
|
83
|
+
}
|
|
84
|
+
// Absolute path, not the bare filename readdirSync returns — same
|
|
85
|
+
// "tar resolves a relative first argument against the calling
|
|
86
|
+
// process's own cwd, not workDir" gotcha fetchPublishedTemplateDir's
|
|
87
|
+
// own comment already documents.
|
|
88
|
+
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
89
|
+
return join(workDir, 'package');
|
|
90
|
+
}
|
|
91
|
+
function readManifest(packageDir) {
|
|
92
|
+
const manifestPath = join(packageDir, 'loopengine.package.json');
|
|
93
|
+
if (!existsSync(manifestPath)) {
|
|
94
|
+
throw new PackageManifestError(`${packageDir} has no loopengine.package.json — not a valid loopengine package.`);
|
|
95
|
+
}
|
|
96
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
97
|
+
if (!manifest.name || !manifest.version || !manifest.loopengineVersion) {
|
|
98
|
+
throw new PackageManifestError('loopengine.package.json must have "name", "version", and "loopengineVersion".');
|
|
99
|
+
}
|
|
100
|
+
return manifest;
|
|
101
|
+
}
|
|
102
|
+
// The installing project's own dependencies.loopengine is itself a
|
|
103
|
+
// range (e.g. "^0.1.10"), not a concrete installed version — checking
|
|
104
|
+
// the *floor* of that range against the manifest's required range is
|
|
105
|
+
// the conservative choice: if even the lowest version the range could
|
|
106
|
+
// resolve to wouldn't satisfy the package, refuse now rather than risk
|
|
107
|
+
// the exact silent-failure gap the Parallel-safe checkbox already hit
|
|
108
|
+
// (a feature that imports an export an *actually installed* older
|
|
109
|
+
// version doesn't have yet, with no error, just quietly not working).
|
|
110
|
+
function checkLoopengineVersion(manifest, installedRangeOverride) {
|
|
111
|
+
let installedRange = installedRangeOverride;
|
|
112
|
+
if (!installedRange) {
|
|
113
|
+
// process.cwd(), not core/agent-registry.ts's own projectDir() —
|
|
114
|
+
// that function resolves relative to *this compiled file's own
|
|
115
|
+
// location*, correct only when agents/ is compiled alongside
|
|
116
|
+
// dist/core/, which doesn't hold for a real scaffolded project
|
|
117
|
+
// (loopengine lives in node_modules/loopengine/dist/, but the
|
|
118
|
+
// project's own package.json is at the project root). Every other
|
|
119
|
+
// cwd-relative path in this CLI (bin/cli.ts's requireAdapterFile,
|
|
120
|
+
// core/gateway-tools.ts's own agentsRootDir, which agentDir() above
|
|
121
|
+
// already relies on throughout this file) already resolves this way
|
|
122
|
+
// — confirmed live: a real `npm pack file:...` install against this
|
|
123
|
+
// repo's own dist/bin/cli.js failed against projectDir() here before
|
|
124
|
+
// this fix, for exactly this reason.
|
|
125
|
+
const ownPkgPath = join(process.cwd(), 'package.json');
|
|
126
|
+
const ownPkg = JSON.parse(readFileSync(ownPkgPath, 'utf8'));
|
|
127
|
+
// A checkout of loopengine's own source (this repo, including its
|
|
128
|
+
// own test suite) has no "loopengine" entry to check against — it
|
|
129
|
+
// can't depend on itself. Same "detected by package.json's own
|
|
130
|
+
// name, not a flag to remember" distinction bin/cli.ts's own
|
|
131
|
+
// configImportSpecifier already makes for exactly this
|
|
132
|
+
// repo-vs-real-consumer-project case.
|
|
133
|
+
if (ownPkg.name === 'loopengine')
|
|
134
|
+
return;
|
|
135
|
+
installedRange = ownPkg.dependencies?.loopengine;
|
|
136
|
+
if (!installedRange) {
|
|
137
|
+
throw new PackageVersionError(`This project's package.json has no "loopengine" dependency — can't check compatibility.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const floor = semver.minVersion(installedRange);
|
|
141
|
+
if (!floor || !semver.satisfies(floor, manifest.loopengineVersion)) {
|
|
142
|
+
throw new PackageVersionError(`Package '${manifest.name}' needs loopengine ${manifest.loopengineVersion}, but this project depends on loopengine ${installedRange} — bump the dependency first.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function parseActauthRules(packageDir, manifest) {
|
|
146
|
+
if (!manifest.actauth)
|
|
147
|
+
return [];
|
|
148
|
+
const rulesPath = join(packageDir, manifest.actauth);
|
|
149
|
+
if (!existsSync(rulesPath)) {
|
|
150
|
+
throw new PackageManifestError(`Manifest references actauth file '${manifest.actauth}', which doesn't exist in the package.`);
|
|
151
|
+
}
|
|
152
|
+
const parsed = parseYaml(readFileSync(rulesPath, 'utf8'));
|
|
153
|
+
if (!Array.isArray(parsed)) {
|
|
154
|
+
throw new PackageManifestError(`'${manifest.actauth}' must be a YAML array of {name, scope, tool, decision} rules.`);
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
// ---- Install ----
|
|
159
|
+
/** Installs `spec` (anything `npm pack` accepts) for `agentName`:
|
|
160
|
+
* writes each manifest-listed tool file and patches tools/index.ts
|
|
161
|
+
* (reusing addToolToIndex exactly as web/http-tool-admin.ts's own
|
|
162
|
+
* createHttpTool does), copies each skill directory verbatim (a raw
|
|
163
|
+
* cpSync, not skills-admin.ts's writeSkill — that regenerates
|
|
164
|
+
* frontmatter from scratch and can't carry a package's own
|
|
165
|
+
* frontmatter/assets), appends each actauth rule (addActauthRule), then
|
|
166
|
+
* splices the newly-imported tools into the live agent registry so
|
|
167
|
+
* they're callable immediately, no restart — same updateAgent call
|
|
168
|
+
* adapters/http.ts's handleHttpToolPost already makes. All-or-nothing:
|
|
169
|
+
* every collision check below runs, and the whole install is refused,
|
|
170
|
+
* before a single file is written. */
|
|
171
|
+
export async function installPackage(agentName, spec, options = {}) {
|
|
172
|
+
const fetch = options.fetchPackageDir ?? fetchPackageDir;
|
|
173
|
+
const packageDir = fetch(spec);
|
|
174
|
+
const manifest = readManifest(packageDir);
|
|
175
|
+
checkLoopengineVersion(manifest, options.installedLoopengineRange);
|
|
176
|
+
const provenance = readProvenance(agentName);
|
|
177
|
+
if (provenance[manifest.name]) {
|
|
178
|
+
throw new PackageAlreadyInstalledError(`Package '${manifest.name}' is already installed for '${agentName}' — use upgrade-package instead.`);
|
|
179
|
+
}
|
|
180
|
+
const toolFiles = manifest.tools ?? [];
|
|
181
|
+
const skillDirs = manifest.skills ?? [];
|
|
182
|
+
const rules = parseActauthRules(packageDir, manifest);
|
|
183
|
+
const toolsDir = join(agentDir(agentName), 'tools');
|
|
184
|
+
const skillsDirPath = join(agentDir(agentName), 'skills');
|
|
185
|
+
// Collision checks — refuse the whole install before writing anything,
|
|
186
|
+
// same "refuse rather than guess" rule HttpToolExistsError/
|
|
187
|
+
// HttpToolIndexShapeError already enforce for a single admin-created
|
|
188
|
+
// tool, just applied package-wide.
|
|
189
|
+
const toolNames = [];
|
|
190
|
+
for (const toolFile of toolFiles) {
|
|
191
|
+
const toolName = basename(toolFile, '.ts');
|
|
192
|
+
if (!TOOL_NAME_PATTERN.test(toolName)) {
|
|
193
|
+
throw new PackageManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
|
|
194
|
+
}
|
|
195
|
+
toolNames.push(toolName);
|
|
196
|
+
if (existsSync(join(toolsDir, `${toolName}.ts`))) {
|
|
197
|
+
throw new PackageCollisionError(`agents/${agentName}/tools/${toolName}.ts already exists.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const skillIds = [];
|
|
201
|
+
for (const skillDir of skillDirs) {
|
|
202
|
+
const skillId = basename(skillDir);
|
|
203
|
+
if (!SKILL_ID_PATTERN.test(skillId)) {
|
|
204
|
+
throw new PackageManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
|
|
205
|
+
}
|
|
206
|
+
skillIds.push(skillId);
|
|
207
|
+
if (existsSync(join(skillsDirPath, skillId))) {
|
|
208
|
+
throw new PackageCollisionError(`agents/${agentName}/skills/${skillId}/ already exists.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
|
|
212
|
+
for (const rule of rules) {
|
|
213
|
+
if (existingRuleNames.has(rule.name)) {
|
|
214
|
+
throw new PackageCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const contentHashes = {};
|
|
218
|
+
// Write tool files, patch tools/index.ts, dynamic-import each fresh
|
|
219
|
+
// file. currentTools is captured once, before any write — accumulate
|
|
220
|
+
// newly-installed tools locally and splice with one final updateAgent
|
|
221
|
+
// call, avoiding the exact double-append hazard handleHttpToolPost's
|
|
222
|
+
// own comment already warns about for a folder-form agent with no
|
|
223
|
+
// cached config.tools (re-reading tools/index.ts after it's already
|
|
224
|
+
// been patched would see the just-added entry a second time).
|
|
225
|
+
if (toolNames.length > 0) {
|
|
226
|
+
const entry = getEntry(agentName);
|
|
227
|
+
if (!entry)
|
|
228
|
+
throw new Error(`Unknown agent '${agentName}'.`);
|
|
229
|
+
const currentTools = entry.config.tools ?? (await loadDefaultTools(entry.config));
|
|
230
|
+
const newTools = [];
|
|
231
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
232
|
+
const indexPath = join(toolsDir, 'index.ts');
|
|
233
|
+
for (const toolFile of toolFiles) {
|
|
234
|
+
const toolName = basename(toolFile, '.ts');
|
|
235
|
+
const code = readFileSync(join(packageDir, toolFile), 'utf8');
|
|
236
|
+
const destPath = join(toolsDir, `${toolName}.ts`);
|
|
237
|
+
writeFileSync(destPath, code);
|
|
238
|
+
contentHashes[`tools/${toolName}.ts`] = sha256(code);
|
|
239
|
+
const exportName = toCamelCase(toolName);
|
|
240
|
+
if (existsSync(indexPath)) {
|
|
241
|
+
addToolToIndex(indexPath, toolName, exportName);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${exportName} } from './${toolName}.js'\n\nexport const tools: ToolDefinition[] = [${exportName}]\n`);
|
|
245
|
+
}
|
|
246
|
+
const mod = (await import(pathToFileURL(destPath).href));
|
|
247
|
+
newTools.push(mod[exportName]);
|
|
248
|
+
}
|
|
249
|
+
updateAgent(agentName, { config: { tools: [...currentTools, ...newTools] } });
|
|
250
|
+
}
|
|
251
|
+
// Copy skill directories verbatim.
|
|
252
|
+
for (const skillDir of skillDirs) {
|
|
253
|
+
const skillId = basename(skillDir);
|
|
254
|
+
const srcPath = join(packageDir, skillDir);
|
|
255
|
+
const destPath = join(skillsDirPath, skillId);
|
|
256
|
+
mkdirSync(skillsDirPath, { recursive: true });
|
|
257
|
+
cpSync(srcPath, destPath, { recursive: true });
|
|
258
|
+
const skillMdPath = join(destPath, 'SKILL.md');
|
|
259
|
+
if (existsSync(skillMdPath)) {
|
|
260
|
+
contentHashes[`skills/${skillId}/SKILL.md`] = sha256(readFileSync(skillMdPath, 'utf8'));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Append actauth rules.
|
|
264
|
+
for (const rule of rules) {
|
|
265
|
+
addActauthRule(agentName, rule);
|
|
266
|
+
}
|
|
267
|
+
provenance[manifest.name] = {
|
|
268
|
+
version: manifest.version,
|
|
269
|
+
tools: toolNames,
|
|
270
|
+
skills: skillIds,
|
|
271
|
+
actauthRules: rules.map((r) => r.name),
|
|
272
|
+
env: manifest.env ?? [],
|
|
273
|
+
contentHashes,
|
|
274
|
+
};
|
|
275
|
+
writeProvenance(agentName, provenance);
|
|
276
|
+
return { installed: [...toolNames.map((n) => `tools/${n}.ts`), ...skillIds.map((id) => `skills/${id}/`), ...rules.map((r) => `actauth:${r.name}`)] };
|
|
277
|
+
}
|
|
278
|
+
// ---- Upgrade ----
|
|
279
|
+
// git merge-file's own exit code *is* its conflict count (0 = clean),
|
|
280
|
+
// not a pass/fail signal — same distinction create-loopengine's own
|
|
281
|
+
// threeWayMerge already documents. Operates on a disposable scratch
|
|
282
|
+
// copy, never the real project file directly.
|
|
283
|
+
function mergeFile(mine, base, theirs) {
|
|
284
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'loopengine-package-merge-'));
|
|
285
|
+
const minePath = join(scratchDir, 'mine');
|
|
286
|
+
const basePath = join(scratchDir, 'base');
|
|
287
|
+
const theirsPath = join(scratchDir, 'theirs');
|
|
288
|
+
writeFileSync(minePath, mine);
|
|
289
|
+
writeFileSync(basePath, base);
|
|
290
|
+
writeFileSync(theirsPath, theirs);
|
|
291
|
+
let conflicted = false;
|
|
292
|
+
try {
|
|
293
|
+
execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], { stdio: 'pipe' });
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
|
|
297
|
+
conflicted = true;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const merged = readFileSync(minePath, 'utf8');
|
|
304
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
305
|
+
return { merged, conflicted };
|
|
306
|
+
}
|
|
307
|
+
/** Upgrades an already-installed package to whatever `spec` (defaulting
|
|
308
|
+
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
309
|
+
* files get a real three-way merge (mine = current file, possibly
|
|
310
|
+
* hand-edited since install; base = the version recorded at
|
|
311
|
+
* install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
|
|
312
|
+
* newly fetched) — identical technique to `create-loopengine upgrade`,
|
|
313
|
+
* just applied to package-managed files instead of template files.
|
|
314
|
+
* actauth rules upgrade per-rule instead: the target actauth.yml holds
|
|
315
|
+
* rules from other packages and hand-written ones too, so there's no
|
|
316
|
+
* coherent "whole file" base/theirs to merge — a rule unchanged since
|
|
317
|
+
* install updates cleanly; a hand-edited one is left alone and reported
|
|
318
|
+
* as a conflict rather than overwritten. */
|
|
319
|
+
export async function upgradePackage(agentName, packageName, options = {}) {
|
|
320
|
+
const fetch = options.fetchPackageDir ?? fetchPackageDir;
|
|
321
|
+
const provenance = readProvenance(agentName);
|
|
322
|
+
const record = provenance[packageName];
|
|
323
|
+
if (!record) {
|
|
324
|
+
throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
|
|
325
|
+
}
|
|
326
|
+
const spec = options.spec ?? packageName;
|
|
327
|
+
const oldDir = fetch(`${spec}@${record.version}`);
|
|
328
|
+
const newDir = fetch(spec);
|
|
329
|
+
const oldManifest = readManifest(oldDir);
|
|
330
|
+
const newManifest = readManifest(newDir);
|
|
331
|
+
checkLoopengineVersion(newManifest, options.installedLoopengineRange);
|
|
332
|
+
const results = [];
|
|
333
|
+
const newContentHashes = { ...record.contentHashes };
|
|
334
|
+
for (const toolName of record.tools) {
|
|
335
|
+
const relPath = `tools/${toolName}.ts`;
|
|
336
|
+
const oldFile = (oldManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
|
|
337
|
+
const newFile = (newManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
|
|
338
|
+
if (!oldFile || !newFile) {
|
|
339
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const base = readFileSync(join(oldDir, oldFile), 'utf8');
|
|
343
|
+
const theirs = readFileSync(join(newDir, newFile), 'utf8');
|
|
344
|
+
if (base === theirs) {
|
|
345
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const minePath = join(agentDir(agentName), relPath);
|
|
349
|
+
const { merged, conflicted } = mergeFile(readFileSync(minePath, 'utf8'), base, theirs);
|
|
350
|
+
writeFileSync(minePath, merged);
|
|
351
|
+
if (!conflicted)
|
|
352
|
+
newContentHashes[relPath] = sha256(merged);
|
|
353
|
+
results.push({ path: relPath, status: conflicted ? 'conflict' : 'updated' });
|
|
354
|
+
}
|
|
355
|
+
for (const skillId of record.skills) {
|
|
356
|
+
const relPath = `skills/${skillId}/SKILL.md`;
|
|
357
|
+
const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === skillId);
|
|
358
|
+
const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === skillId);
|
|
359
|
+
const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
|
|
360
|
+
const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
|
|
361
|
+
if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
|
|
362
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const base = readFileSync(oldFile, 'utf8');
|
|
366
|
+
const theirs = readFileSync(newFile, 'utf8');
|
|
367
|
+
if (base === theirs) {
|
|
368
|
+
results.push({ path: relPath, status: 'unchanged' });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const minePath = join(agentDir(agentName), relPath);
|
|
372
|
+
const { merged, conflicted } = mergeFile(readFileSync(minePath, 'utf8'), base, theirs);
|
|
373
|
+
writeFileSync(minePath, merged);
|
|
374
|
+
if (!conflicted)
|
|
375
|
+
newContentHashes[relPath] = sha256(merged);
|
|
376
|
+
results.push({ path: relPath, status: conflicted ? 'conflict' : 'updated' });
|
|
377
|
+
}
|
|
378
|
+
const oldRules = parseActauthRules(oldDir, oldManifest);
|
|
379
|
+
const newRules = parseActauthRules(newDir, newManifest);
|
|
380
|
+
const currentRules = new Map(readActauthConfig(agentName).rules.map((r) => [r.name, r]));
|
|
381
|
+
for (const ruleName of record.actauthRules) {
|
|
382
|
+
const oldRule = oldRules.find((r) => r.name === ruleName);
|
|
383
|
+
const newRule = newRules.find((r) => r.name === ruleName);
|
|
384
|
+
const current = currentRules.get(ruleName);
|
|
385
|
+
const path = `actauth:${ruleName}`;
|
|
386
|
+
if (!oldRule || !newRule || !current) {
|
|
387
|
+
results.push({ path, status: 'unchanged' });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const unchangedSinceInstall = current.scope === oldRule.scope && current.tool === oldRule.tool && current.decision === oldRule.decision;
|
|
391
|
+
if (!unchangedSinceInstall) {
|
|
392
|
+
results.push({ path, status: 'conflict' });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (oldRule.scope === newRule.scope && oldRule.tool === newRule.tool && oldRule.decision === newRule.decision) {
|
|
396
|
+
results.push({ path, status: 'unchanged' });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
400
|
+
results.push({ path, status: 'updated' });
|
|
401
|
+
}
|
|
402
|
+
provenance[packageName] = { ...record, version: newManifest.version, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
403
|
+
writeProvenance(agentName, provenance);
|
|
404
|
+
return { files: results };
|
|
405
|
+
}
|
|
406
|
+
// ---- Remove ----
|
|
407
|
+
function isDirty(path, recordedHash) {
|
|
408
|
+
if (!existsSync(path))
|
|
409
|
+
return false;
|
|
410
|
+
if (!recordedHash)
|
|
411
|
+
return true;
|
|
412
|
+
return sha256(readFileSync(path, 'utf8')) !== recordedHash;
|
|
413
|
+
}
|
|
414
|
+
/** Removes an installed package's tool and skill files, and its actauth
|
|
415
|
+
* rules. A file whose content no longer matches the hash recorded at
|
|
416
|
+
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
417
|
+
* unless `force` is passed — a concrete implementation of PACKAGES.md's
|
|
418
|
+
* "refuses if any of them look hand-modified," cheaper than storing full
|
|
419
|
+
* content or refetching the package to diff. Does not patch
|
|
420
|
+
* tools/index.ts to remove the now-dangling import — same "refuse
|
|
421
|
+
* rather than guess a second time" doctrine addToolToIndex's own doc
|
|
422
|
+
* comment already applies; a stale import surfaces as a clear build
|
|
423
|
+
* error, not a silent break. */
|
|
424
|
+
export function removePackage(agentName, packageName, force = false) {
|
|
425
|
+
const provenance = readProvenance(agentName);
|
|
426
|
+
const record = provenance[packageName];
|
|
427
|
+
if (!record) {
|
|
428
|
+
throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
|
|
429
|
+
}
|
|
430
|
+
const removed = [];
|
|
431
|
+
const refused = [];
|
|
432
|
+
const remainingTools = [];
|
|
433
|
+
const remainingSkills = [];
|
|
434
|
+
for (const toolName of record.tools) {
|
|
435
|
+
const relPath = `tools/${toolName}.ts`;
|
|
436
|
+
const fullPath = join(agentDir(agentName), relPath);
|
|
437
|
+
if (!force && isDirty(fullPath, record.contentHashes[relPath])) {
|
|
438
|
+
refused.push(relPath);
|
|
439
|
+
remainingTools.push(toolName);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
rmSync(fullPath, { force: true });
|
|
443
|
+
removed.push(relPath);
|
|
444
|
+
}
|
|
445
|
+
for (const skillId of record.skills) {
|
|
446
|
+
const relPath = `skills/${skillId}/SKILL.md`;
|
|
447
|
+
const fullPath = join(agentDir(agentName), relPath);
|
|
448
|
+
if (!force && isDirty(fullPath, record.contentHashes[relPath])) {
|
|
449
|
+
refused.push(relPath);
|
|
450
|
+
remainingSkills.push(skillId);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
rmSync(join(agentDir(agentName), 'skills', skillId), { recursive: true, force: true });
|
|
454
|
+
removed.push(`skills/${skillId}/`);
|
|
455
|
+
}
|
|
456
|
+
for (const ruleName of record.actauthRules) {
|
|
457
|
+
try {
|
|
458
|
+
removeActauthRule(agentName, ruleName);
|
|
459
|
+
removed.push(`actauth:${ruleName}`);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Already gone (hand-removed since install) — not a failure.
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (refused.length === 0) {
|
|
466
|
+
delete provenance[packageName];
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
provenance[packageName] = { ...record, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
|
|
470
|
+
}
|
|
471
|
+
writeProvenance(agentName, provenance);
|
|
472
|
+
return { removed, refused };
|
|
473
|
+
}
|
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
|
|
@@ -1569,6 +1660,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1569
1660
|
'<button type="button" id="httpToolAddHeaderBtn" class="hint-btn">Add header</button>' +
|
|
1570
1661
|
'</label>' +
|
|
1571
1662
|
'<label class="http-tool-inline-label"><input type="checkbox" name="sendFieldsAsJsonBody"> Send fields as a JSON body (POST/PUT/PATCH only)</label>' +
|
|
1663
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="safe"> Parallel-safe <span class="hint">(read-only, no side effects — safe to run alongside other calls)</span></label>' +
|
|
1572
1664
|
'<label>Response JSON path <span class="hint">(optional — e.g. data.status; leave blank to return the raw response text)</span>' +
|
|
1573
1665
|
'<input type="text" name="responseJsonPath" placeholder="data.status">' +
|
|
1574
1666
|
'</label>' +
|
|
@@ -1655,6 +1747,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1655
1747
|
}
|
|
1656
1748
|
|
|
1657
1749
|
form.querySelector('[name="sendFieldsAsJsonBody"]').checked = spec.sendFieldsAsJsonBody;
|
|
1750
|
+
form.querySelector('[name="safe"]').checked = spec.safe === true;
|
|
1658
1751
|
form.querySelector('[name="responseJsonPath"]').value = spec.responseJsonPath || '';
|
|
1659
1752
|
}
|
|
1660
1753
|
|
|
@@ -1736,6 +1829,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1736
1829
|
url: formData.get('url'),
|
|
1737
1830
|
headers: headers,
|
|
1738
1831
|
sendFieldsAsJsonBody: formData.get('sendFieldsAsJsonBody') === 'on',
|
|
1832
|
+
safe: formData.get('safe') === 'on',
|
|
1739
1833
|
responseJsonPath: formData.get('responseJsonPath') ? formData.get('responseJsonPath') : undefined,
|
|
1740
1834
|
decision: formData.get('decision') ? formData.get('decision') : undefined,
|
|
1741
1835
|
};
|
|
@@ -2085,6 +2179,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2085
2179
|
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
2086
2180
|
loadActauthTab(currentName);
|
|
2087
2181
|
}
|
|
2182
|
+
if (tab === 'env' && envLoadedFor !== currentName) {
|
|
2183
|
+
loadEnvTab(currentName);
|
|
2184
|
+
}
|
|
2088
2185
|
}
|
|
2089
2186
|
|
|
2090
2187
|
// Refreshes just Overview's own summary — used after anything that
|
|
@@ -2160,11 +2257,13 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2160
2257
|
'<button class="tab" data-tab="skills">Skills</button>' +
|
|
2161
2258
|
'<button class="tab" data-tab="tools">Tools</button>' +
|
|
2162
2259
|
'<button class="tab" data-tab="actauth">ActAuth</button>' +
|
|
2260
|
+
'<button class="tab" data-tab="env">Environment</button>' +
|
|
2163
2261
|
'</div>' +
|
|
2164
2262
|
'<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
|
|
2165
2263
|
'<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
|
|
2166
2264
|
'<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
|
|
2167
|
-
'<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>';
|
|
2168
2267
|
|
|
2169
2268
|
var buttons = detail.querySelectorAll('.tabs button');
|
|
2170
2269
|
for (var i = 0; i < buttons.length; i++) {
|
|
@@ -2188,6 +2287,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
2188
2287
|
currentName = name;
|
|
2189
2288
|
gatewayLoadedFor = null;
|
|
2190
2289
|
actauthLoadedFor = null;
|
|
2290
|
+
envLoadedFor = null;
|
|
2191
2291
|
var items = agentList.querySelectorAll('li');
|
|
2192
2292
|
for (var i = 0; i < items.length; i++) {
|
|
2193
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
|
+
}
|
|
@@ -27,7 +27,17 @@ export interface HttpToolSpec {
|
|
|
27
27
|
}[];
|
|
28
28
|
sendFieldsAsJsonBody: boolean;
|
|
29
29
|
responseJsonPath?: string;
|
|
30
|
+
/** Same meaning as ToolDefinition.safe (core/agent-config.ts) — true
|
|
31
|
+
* only for a read-only call with no side effects, so ToolLane can run
|
|
32
|
+
* it in its parallel lane. Defaults to false/omitted (unsafe) rather
|
|
33
|
+
* than inferring from `method`, since a GET can still have side
|
|
34
|
+
* effects (e.g. a webhook-triggering endpoint) and the operator who
|
|
35
|
+
* knows the real API is in a better position to say than a guess
|
|
36
|
+
* based on HTTP verb alone. */
|
|
37
|
+
safe?: boolean;
|
|
30
38
|
}
|
|
39
|
+
export declare function toCamelCase(snakeCase: string): string;
|
|
40
|
+
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
|
|
31
41
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
32
42
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
33
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
|
|
@@ -171,6 +171,7 @@ ${envReads} const res = await fetch(\`${urlInner}\`, {
|
|
|
171
171
|
${headersObj}${bodyLine} })
|
|
172
172
|
if (!res.ok) throw new Error(\`${spec.name}: HTTP \${res.status}\`)
|
|
173
173
|
${responseHandling} },
|
|
174
|
+
safe: ${spec.safe === true},
|
|
174
175
|
}
|
|
175
176
|
`;
|
|
176
177
|
}
|
|
@@ -184,7 +185,7 @@ ${responseHandling} },
|
|
|
184
185
|
// pattern doesn't recognize, it refuses instead of risking corrupting it
|
|
185
186
|
// — same "refuse rather than guess" discipline agent-file-admin.ts's own
|
|
186
187
|
// doc comment already establishes for index.ts edits.
|
|
187
|
-
function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
188
|
+
export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
188
189
|
const source = readFileSync(toolsIndexPath, 'utf8');
|
|
189
190
|
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
190
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.11",
|
|
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
|
}
|