loopengine 0.1.10 → 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 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&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.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
  }