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 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&hellip;</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&hellip;</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.10",
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
  }