@seqyuan/ennote 0.4.0 → 0.6.0

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.
Files changed (51) hide show
  1. package/README.md +116 -13
  2. package/bin/ennote.js +539 -28
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +4 -4
  5. package/out/__next._full.txt +16 -16
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +4 -4
  9. package/out/_next/static/chunks/{0l9f15ts9g8~w.css → 042svogz3qv71.css} +3 -2
  10. package/out/_next/static/chunks/05rc5pno5-uub.js +8 -0
  11. package/out/_next/static/chunks/0_yt3a8a9j1yd.js +1 -0
  12. package/out/_next/static/chunks/0gee9kb6nwlbz.js +1 -0
  13. package/out/_next/static/chunks/{0j5eflqzx5_i2.css → 0k-~g5-_lbznv.css} +1 -1
  14. package/out/_next/static/chunks/0r3uxu723bo0o.css +4 -0
  15. package/out/_not-found/__next._full.txt +14 -14
  16. package/out/_not-found/__next._head.txt +4 -4
  17. package/out/_not-found/__next._index.txt +8 -8
  18. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  19. package/out/_not-found/__next._not-found.txt +3 -3
  20. package/out/_not-found/__next._tree.txt +3 -3
  21. package/out/_not-found.html +1 -1
  22. package/out/_not-found.txt +14 -14
  23. package/out/graphs/__next._full.txt +16 -16
  24. package/out/graphs/__next._head.txt +4 -4
  25. package/out/graphs/__next._index.txt +8 -8
  26. package/out/graphs/__next._tree.txt +4 -4
  27. package/out/graphs/__next.graphs.__PAGE__.txt +4 -4
  28. package/out/graphs/__next.graphs.txt +3 -3
  29. package/out/graphs.html +1 -1
  30. package/out/graphs.txt +16 -16
  31. package/out/index.html +2 -2
  32. package/out/index.txt +16 -16
  33. package/out/roles/__next._full.txt +16 -16
  34. package/out/roles/__next._head.txt +4 -4
  35. package/out/roles/__next._index.txt +8 -8
  36. package/out/roles/__next._tree.txt +4 -4
  37. package/out/roles/__next.roles.__PAGE__.txt +4 -4
  38. package/out/roles/__next.roles.txt +3 -3
  39. package/out/roles.html +1 -1
  40. package/out/roles.txt +16 -16
  41. package/package.json +29 -5
  42. package/skills/bio-workspace/SKILL.md +11 -0
  43. package/skills/bio-workspace/skill.json +7 -0
  44. package/skills/skill-creator/SKILL.md +87 -0
  45. package/skills/skill-creator/skill.json +7 -0
  46. package/out/_next/static/chunks/0qavlry.eoeqb.js +0 -1
  47. package/out/_next/static/chunks/0uuaelr0o3sep.css +0 -4
  48. package/out/_next/static/chunks/0yuqqoxr67j_q.js +0 -8
  49. /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → 1IULeStFBlnqxNnZLaKnI}/_buildManifest.js +0 -0
  50. /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → 1IULeStFBlnqxNnZLaKnI}/_clientMiddlewareManifest.js +0 -0
  51. /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → 1IULeStFBlnqxNnZLaKnI}/_ssgManifest.js +0 -0
package/bin/ennote.js CHANGED
@@ -16,7 +16,20 @@ const DEFAULT_PORT = "45131";
16
16
  const DEFAULT_HOSTNAME = "127.0.0.1";
17
17
  const START_TIMEOUT_MS = 45_000;
18
18
  const STOP_TIMEOUT_MS = 15_000;
19
- const GITHUB_RELEASES_BASE = "https://github.com/seqyuan/ennote/releases/download";
19
+ const PACK_INSPECT_TIMEOUT_MS = 15_000;
20
+ const PACK_REQUEST_TIMEOUT_MS = 15_000;
21
+ const DEFAULT_RELEASES_BASE = "https://github.com/seqyuan/ennote/releases/download";
22
+
23
+ // releasesBase honours ENNOTE_RELEASES_BASE so a mirror (or an air-gapped
24
+ // install serving the release assets locally) can be used instead of GitHub.
25
+ // The value is read at call time, not import time, so a wrapper script that
26
+ // sets it still affects this process. HTTPS is enforced by requireHTTPS.
27
+ function releasesBase() {
28
+ const override = String(process.env.ENNOTE_RELEASES_BASE ?? "").trim();
29
+ return override === "" ? DEFAULT_RELEASES_BASE : override.replace(/\/+$/, "");
30
+ }
31
+
32
+ const BINARY_NAMES = ["ennogate", "ennoworker"];
20
33
 
21
34
  function platformSuffix(platform = os.platform(), arch = os.arch()) {
22
35
  if (!new Set(["linux", "darwin"]).has(platform)) {
@@ -54,6 +67,18 @@ function statePaths(home) {
54
67
  };
55
68
  }
56
69
 
70
+ // workerStatePath is the owner-only handshake file ennoworker writes and
71
+ // ennogate supervises (see internal/runtimeinfo.WorkerState).
72
+ function workerStatePath(home) {
73
+ return path.join(home, "runtime", "worker-state.json");
74
+ }
75
+
76
+ function usageError(message) {
77
+ const error = new Error(message);
78
+ error.exitCode = 2;
79
+ return error;
80
+ }
81
+
57
82
  function validateRuntime(paths) {
58
83
  for (const [name, file] of [["ennogate", paths.gate], ["ennoworker", paths.worker]]) {
59
84
  if (!fs.existsSync(file)) throw new Error(`Missing ${name} binary: ${file}`);
@@ -87,6 +112,53 @@ function removeFile(file) {
87
112
  }
88
113
  }
89
114
 
115
+ // compareVersions orders dotted numeric versions. A pre-release suffix sorts
116
+ // before the release it belongs to, so 0.5.0-rc1 < 0.5.0.
117
+ function compareVersions(left, right) {
118
+ const parse = (value) => {
119
+ const [core, ...pre] = String(value).split("-");
120
+ return { parts: core.split(".").map((part) => Number.parseInt(part, 10) || 0), pre: pre.join("-") };
121
+ };
122
+ const a = parse(left);
123
+ const b = parse(right);
124
+ const length = Math.max(a.parts.length, b.parts.length);
125
+ for (let index = 0; index < length; index += 1) {
126
+ const difference = (a.parts[index] ?? 0) - (b.parts[index] ?? 0);
127
+ if (difference !== 0) return difference;
128
+ }
129
+ if (a.pre === b.pre) return 0;
130
+ if (a.pre === "") return 1;
131
+ if (b.pre === "") return -1;
132
+ return a.pre < b.pre ? -1 : 1;
133
+ }
134
+
135
+ // cachedBinaryVersions lists the versions that already have BOTH binaries for
136
+ // this platform, newest first. A version with only one binary is an abandoned
137
+ // partial download and must not be offered as a fallback. Selection is explicit
138
+ // rather than readdir order, which is filesystem-defined.
139
+ function cachedBinaryVersions(home, suffix) {
140
+ const binDir = runtimeBinDir(home);
141
+ let entries;
142
+ try {
143
+ entries = fs.readdirSync(binDir);
144
+ } catch (error) {
145
+ if (error?.code === "ENOENT") return [];
146
+ throw error;
147
+ }
148
+ const versions = new Set();
149
+ for (const entry of entries) {
150
+ for (const name of BINARY_NAMES) {
151
+ const postfix = `-${suffix}`;
152
+ if (!entry.startsWith(`${name}-`) || !entry.endsWith(postfix)) continue;
153
+ const version = entry.slice(name.length + 1, entry.length - postfix.length);
154
+ if (version !== "") versions.add(version);
155
+ }
156
+ }
157
+ return [...versions]
158
+ .filter((version) => BINARY_NAMES.every((name) => fs.existsSync(path.join(binDir, `${name}-${version}-${suffix}`))))
159
+ .sort((a, b) => compareVersions(b, a));
160
+ }
161
+
90
162
  function isPidAlive(pid) {
91
163
  if (!Number.isInteger(pid) || pid <= 0) return false;
92
164
  try {
@@ -246,33 +318,56 @@ function downloadText(url) {
246
318
  });
247
319
  }
248
320
 
249
- async function ensureBinaries(home, pkg, log, platform = os.platform(), arch = os.arch()) {
321
+ async function ensureBinaries(home, pkg, log, platform = os.platform(), arch = os.arch(), download = {}) {
250
322
  const suffix = platformSuffix(platform, arch);
251
323
  const version = pkg.version;
252
324
  const binDir = runtimeBinDir(home);
253
325
  ensureDirectory(binDir);
254
- const gatePath = path.join(binDir, `ennogate-${version}-${suffix}`);
255
- const workerPath = path.join(binDir, `ennoworker-${version}-${suffix}`);
256
- const needed = [];
257
- if (!fs.existsSync(gatePath)) needed.push({ name: "ennogate", path: gatePath });
258
- if (!fs.existsSync(workerPath)) needed.push({ name: "ennoworker", path: workerPath });
259
- if (needed.length === 0) return;
260
- log(`Downloading ${needed.length} binary(s) for ${suffix}...`);
261
- const sumsUrl = `${GITHUB_RELEASES_BASE}/v${version}/SHA256SUMS`;
262
- const sumsText = await downloadText(sumsUrl);
263
- for (const { name, path: dest } of needed) {
264
- const filename = `${name}-${suffix}`;
265
- const expected = parseSHA256SUMS(sumsText, filename);
266
- const url = `${GITHUB_RELEASES_BASE}/v${version}/${filename}`;
267
- await downloadFile(url, dest);
268
- try {
269
- verifyFileSHA256(dest, expected);
270
- } catch (error) {
271
- try { fs.unlinkSync(dest); } catch {}
272
- throw error;
326
+ const binaryPath = (name, at) => path.join(binDir, `${name}-${at}-${suffix}`);
327
+ const missing = BINARY_NAMES.filter((name) => !fs.existsSync(binaryPath(name, version)));
328
+ if (missing.length === 0) return { version, fallback: false };
329
+
330
+ const base = releasesBase();
331
+ const fetchText = download.downloadText ?? downloadText;
332
+ const fetchFile = download.downloadFile ?? downloadFile;
333
+ const sumsUrl = `${base}/v${version}/SHA256SUMS`;
334
+ log(`Downloading ${missing.length} binary(s) for ${suffix}...`);
335
+ try {
336
+ const sumsText = await fetchText(sumsUrl);
337
+ for (const name of missing) {
338
+ const filename = `${name}-${suffix}`;
339
+ const dest = binaryPath(name, version);
340
+ const expected = parseSHA256SUMS(sumsText, filename);
341
+ try {
342
+ await fetchFile(`${base}/v${version}/${filename}`, dest);
343
+ verifyFileSHA256(dest, expected);
344
+ } catch (error) {
345
+ // Never leave a partial or unverified binary behind: it would look like
346
+ // a usable cache entry to the next run.
347
+ removeFile(dest);
348
+ throw error;
349
+ }
350
+ }
351
+ return { version, fallback: false };
352
+ } catch (error) {
353
+ // Downloading needs GitHub. A local-first runtime should still start from a
354
+ // binary that is already on disk rather than fail on a network the user may
355
+ // not have, so an older cached version is better than no server.
356
+ const fallback = cachedBinaryVersions(home, suffix)[0];
357
+ if (!fallback) {
358
+ throw new Error(
359
+ `failed to install Ennote ${version} binaries: ${error.message}; ` +
360
+ `attempted ${sumsUrl} and found no complete cached build under ${binDir} for ${suffix}. ` +
361
+ `Install the release assets manually or set ENNOTE_RELEASES_BASE to a mirror.`,
362
+ );
273
363
  }
364
+ log(
365
+ `warning: failed to install Ennote ${version} binaries (${error.message}); ` +
366
+ `using cached ${fallback} for ${suffix} instead. ` +
367
+ `The Worker is older than the CLI's expected ${version}; re-run with network access to update.`,
368
+ );
369
+ return { version: fallback, fallback: true };
274
370
  }
275
- log("Binaries ready.");
276
371
  }
277
372
 
278
373
  async function startService(options) {
@@ -290,8 +385,8 @@ async function startService(options) {
290
385
  if (!await canBind(port, hostname)) throw new Error(`Port ${port} is already in use on ${hostname}`);
291
386
 
292
387
  const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
293
- await ensureBinaries(home, pkg, log);
294
- const runtime = runtimePaths(packageDir, home, pkg.version);
388
+ const binaries = await ensureBinaries(home, pkg, log);
389
+ const runtime = runtimePaths(packageDir, home, binaries.version);
295
390
  validateRuntime(runtime);
296
391
  ensureDirectory(home);
297
392
  const output = fs.openSync(files.log, "a", 0o600);
@@ -302,6 +397,9 @@ async function startService(options) {
302
397
  ENNOTE_HOME: home,
303
398
  ENNOTE_STATIC_DIR: runtime.staticDir,
304
399
  ENNOTE_WORKER_PATH: runtime.worker,
400
+ ...(fs.existsSync(path.join(packageDir, "skills"))
401
+ ? { ENNOTE_BUILTIN_SKILLS_DIR: path.join(packageDir, "skills") }
402
+ : {}),
305
403
  PORT: String(port),
306
404
  ENNOTE_HOSTNAME: hostname,
307
405
  },
@@ -315,9 +413,14 @@ async function startService(options) {
315
413
  port: String(port),
316
414
  hostname,
317
415
  url,
416
+ // Which build is actually serving: an offline start can run a cached
417
+ // version older than this CLI, and that has to be recoverable after the
418
+ // fact rather than only visible in one warning line.
419
+ binaryVersion: binaries.version,
420
+ binaryFallback: binaries.fallback,
318
421
  startedAt: new Date().toISOString(),
319
422
  });
320
- log(`starting ennogate pid=${child.pid} url=${url}`);
423
+ log(`starting ennogate pid=${child.pid} url=${url} bin=${binaries.version}${binaries.fallback ? " (cached fallback)" : ""}`);
321
424
 
322
425
  const deadline = Date.now() + START_TIMEOUT_MS;
323
426
  while (Date.now() < deadline) {
@@ -359,7 +462,381 @@ async function serviceStatus(home) {
359
462
  return { running: false, stale: Boolean(state) };
360
463
  }
361
464
  const healthy = await healthCheck(state.url);
362
- return { running: healthy, healthy, pid: state.pid, url: state.url, port: state.port };
465
+ return {
466
+ running: healthy,
467
+ healthy,
468
+ pid: state.pid,
469
+ url: state.url,
470
+ port: state.port,
471
+ ...(state.binaryVersion ? { binaryVersion: state.binaryVersion, binaryFallback: Boolean(state.binaryFallback) } : {}),
472
+ };
473
+ }
474
+
475
+ function isLoopbackHostname(hostname) {
476
+ const name = String(hostname ?? "").replace(/^\[|\]$/g, "").toLowerCase();
477
+ if (name === "localhost" || name === "::1") return true;
478
+ const match = name.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
479
+ return Boolean(match) && match.slice(1).every(part => Number(part) <= 255);
480
+ }
481
+
482
+ // loadWorkerEndpoint reads the Worker handshake written by ennoworker and
483
+ // refuses anything that is not a live loopback http Worker: ennote pack inspect
484
+ // talks to the real Worker instead of re-implementing pack parsing in Node.
485
+ function loadWorkerEndpoint(home) {
486
+ const file = workerStatePath(home);
487
+ if (!fs.existsSync(file)) {
488
+ throw new Error(`Ennote Worker is not running (${file} is missing). Start it with: ennote start`);
489
+ }
490
+ let state;
491
+ try {
492
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
493
+ } catch {
494
+ throw new Error(`Ennote Worker state ${file} is unreadable. Restart it with: ennote restart`);
495
+ }
496
+ let parsed;
497
+ try {
498
+ parsed = new URL(String(state?.url ?? ""));
499
+ } catch {
500
+ throw new Error(`Ennote Worker state ${file} has an invalid url: ${state?.url ?? ""}`);
501
+ }
502
+ if (parsed.protocol !== "http:") {
503
+ throw new Error(`refusing Worker url ${state.url}: expected http:`);
504
+ }
505
+ if (!isLoopbackHostname(parsed.hostname)) {
506
+ throw new Error(`refusing non-loopback Worker url ${state.url}`);
507
+ }
508
+ if (!isPidAlive(state.pid)) {
509
+ throw new Error(`Ennote Worker pid ${state.pid} is not running. Start it with: ennote start`);
510
+ }
511
+ if (typeof state.bootstrapToken !== "string" || state.bootstrapToken === "") {
512
+ throw new Error(`Ennote Worker state ${file} has no bootstrap token. Restart it with: ennote restart`);
513
+ }
514
+ return { url: parsed.origin, token: state.bootstrapToken, pid: state.pid, instanceId: state.instanceId };
515
+ }
516
+
517
+ function postJSON(url, body, headers, timeoutMs) {
518
+ return httpRequest("POST", url, body, headers, timeoutMs);
519
+ }
520
+
521
+ // httpRequest is the one place the CLI talks to the Worker. GET/DELETE carry no
522
+ // body; POST serializes it. The Worker url is already pinned to loopback http by
523
+ // loadWorkerEndpoint, so no transport negotiation happens here.
524
+ function httpRequest(method, url, body, headers, timeoutMs) {
525
+ return new Promise((resolve, reject) => {
526
+ const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), "utf8");
527
+ const request = http.request(url, {
528
+ method,
529
+ headers: payload
530
+ ? { "Content-Type": "application/json", "Content-Length": payload.length, ...headers }
531
+ : headers,
532
+ }, (response) => {
533
+ const chunks = [];
534
+ response.on("data", (chunk) => chunks.push(chunk));
535
+ response.on("end", () => resolve({
536
+ statusCode: response.statusCode ?? 0,
537
+ body: Buffer.concat(chunks).toString("utf8"),
538
+ }));
539
+ });
540
+ request.once("error", reject);
541
+ request.setTimeout(timeoutMs, () => {
542
+ request.destroy(new Error(`Worker request timed out after ${timeoutMs / 1000}s`));
543
+ });
544
+ request.end(payload ?? undefined);
545
+ });
546
+ }
547
+
548
+ // decodeWorkerResponse unwraps the Worker error envelope and the data envelope.
549
+ // A 204 (pack remove) has no data and yields null.
550
+ function decodeWorkerResponse(response, action) {
551
+ if (response.statusCode < 200 || response.statusCode >= 300) {
552
+ let message = `Worker returned HTTP ${response.statusCode}`;
553
+ let code;
554
+ try {
555
+ const parsed = JSON.parse(response.body);
556
+ code = parsed?.error?.code;
557
+ if (parsed?.error?.message) message = parsed.error.message;
558
+ } catch {
559
+ // A non-JSON error body keeps the status-only message.
560
+ }
561
+ const error = new Error(code ? `${message} (${code})` : message);
562
+ error.code = code;
563
+ error.statusCode = response.statusCode;
564
+ throw error;
565
+ }
566
+ if (response.statusCode === 204 || response.body.trim() === "") return null;
567
+ let parsed;
568
+ try {
569
+ parsed = JSON.parse(response.body);
570
+ } catch {
571
+ throw new Error(`Worker returned an unreadable ${action} response`);
572
+ }
573
+ if (!parsed || typeof parsed !== "object" || parsed.data == null) {
574
+ throw new Error(`Worker returned an unexpected ${action} response`);
575
+ }
576
+ return parsed.data;
577
+ }
578
+
579
+ // requestJSON performs one authenticated Worker call. Every pack command except
580
+ // install is GET/DELETE; install reuses postJSON so its body and headers stay in
581
+ // one place.
582
+ async function requestJSON(endpoint, method, pathname, options = {}) {
583
+ if (method === "POST") {
584
+ const response = await postJSON(
585
+ `${endpoint.url}${pathname}`,
586
+ options.body,
587
+ { Authorization: `Bearer ${endpoint.token}` },
588
+ options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
589
+ );
590
+ return decodeWorkerResponse(response, options.action ?? "pack");
591
+ }
592
+ const response = await httpRequest(
593
+ method,
594
+ `${endpoint.url}${pathname}`,
595
+ undefined,
596
+ { Authorization: `Bearer ${endpoint.token}` },
597
+ options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
598
+ );
599
+ return decodeWorkerResponse(response, options.action ?? "pack");
600
+ }
601
+
602
+ async function requestPackInspect(endpoint, target, options = {}) {
603
+ const body = {
604
+ source: { kind: "path", path: target },
605
+ includeDocuments: Boolean(options.documents),
606
+ };
607
+ if (options.expectedDigest) body.expectedDigest = options.expectedDigest;
608
+ const response = await postJSON(
609
+ `${endpoint.url}/v1/packs/inspect`,
610
+ body,
611
+ { Authorization: `Bearer ${endpoint.token}` },
612
+ options.timeoutMs ?? PACK_INSPECT_TIMEOUT_MS,
613
+ );
614
+ return decodeWorkerResponse(response, "inspect");
615
+ }
616
+
617
+ function formatPackSummary(data) {
618
+ const pack = data.pack ?? {};
619
+ const container = data.container ?? {};
620
+ const signature = data.signature ?? {};
621
+ const lines = [];
622
+ lines.push(`${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
623
+ if (pack.description) lines.push(pack.description);
624
+ lines.push(`Source: ${data.source?.path ?? "?"} (${container.kind ?? "?"}, ${container.bytes ?? 0} bytes)`);
625
+ lines.push(signature.present
626
+ ? `Signature: ${signature.algorithm} at ${signature.path} — UNVERIFIED (inspect never verifies signatures)`
627
+ : "Signature: Unsigned");
628
+ lines.push(`Manifest: ${data.manifestDigest ?? "?"}`);
629
+ lines.push(`Pack: ${data.packDigest ?? "?"}`);
630
+
631
+ const flows = data.flows ?? [];
632
+ lines.push(`Flows (${flows.length})`);
633
+ for (const flow of flows) {
634
+ lines.push(` - ${flow.id} [${flow.format}] ${flow.taskCount} task(s), ${flow.checkTasks} check, ${flow.fanOutTasks} fan-out, ${flow.maxTotalTokens} tokens`);
635
+ }
636
+ const roles = data.roles ?? [];
637
+ lines.push(`Roles (${roles.length})`);
638
+ for (const role of roles) {
639
+ lines.push(` - ${role.handle} (${role.scope}) ${role.authority}, tools: ${(role.allowedTools ?? []).join(", ") || "none"}`);
640
+ }
641
+ const skills = data.skills ?? [];
642
+ lines.push(`Skills (${skills.length})`);
643
+ for (const skill of skills) {
644
+ lines.push(` - ${skill.kind === "external" ? skill.ref : skill.id} (${skill.kind})`);
645
+ }
646
+ // External MCP profile refs are reported but never resolved: the summary says
647
+ // so explicitly so a reviewer does not read the line as an installed profile.
648
+ const mcp = data.mcp ?? [];
649
+ if (mcp.length > 0) {
650
+ lines.push(`MCP (${mcp.length})`);
651
+ for (const profile of mcp) {
652
+ lines.push(` - ${profile.ref} (external ref, not resolved or executed)`);
653
+ }
654
+ }
655
+ const envelope = data.capabilityEnvelope ?? {};
656
+ lines.push(`Checks: ${(envelope.checkCommands ?? []).join(", ") || "none"}`);
657
+ lines.push(`Writes: ${(envelope.writes ?? []).join(", ") || "none"}`);
658
+ lines.push(`Files: ${data.filesTotal ?? 0}${data.filesTruncated ? " (truncated)" : ""}`);
659
+
660
+ const warnings = data.warnings ?? [];
661
+ lines.push(`Warnings (${warnings.length})`);
662
+ for (const warning of warnings) lines.push(` - ${warning.code} (${warning.severity}): ${warning.message}`);
663
+ return lines.join("\n");
664
+ }
665
+
666
+ async function packCommand(home, positionals, values) {
667
+ const action = positionals[1];
668
+ const target = positionals[2];
669
+ if (action === "inspect") {
670
+ if (!target) {
671
+ throw usageError("Usage: ennote pack inspect <file|dir> [--json] [--documents]");
672
+ }
673
+ const endpoint = loadWorkerEndpoint(home);
674
+ const data = await requestPackInspect(endpoint, path.resolve(target), {
675
+ documents: values.documents,
676
+ });
677
+ if (values.json) console.log(JSON.stringify(data));
678
+ else console.log(formatPackSummary(data));
679
+ return;
680
+ }
681
+ if (action === "install") {
682
+ if (!target) {
683
+ throw usageError("Usage: ennote pack install <file|dir> [--dry-run] [--json] [--as-flow old=new] [--as-role old=new]");
684
+ }
685
+ // Renames are validated before the Worker is contacted, so a typo is a
686
+ // usage error instead of a refused install.
687
+ const renames = {
688
+ flows: parsePackRenames(values["as-flow"], "--as-flow"),
689
+ roles: parsePackRenames(values["as-role"], "--as-role"),
690
+ };
691
+ const endpoint = loadWorkerEndpoint(home);
692
+ const plan = await requestPackInstall(endpoint, path.resolve(target), {
693
+ dryRun: Boolean(values["dry-run"]),
694
+ renames,
695
+ });
696
+ if (values.json) console.log(JSON.stringify(plan));
697
+ else console.log(formatPackInstallPlan(plan, { dryRun: Boolean(values["dry-run"]) }));
698
+ return;
699
+ }
700
+ if (action === "list") {
701
+ const endpoint = loadWorkerEndpoint(home);
702
+ const summaries = await requestJSON(endpoint, "GET", "/v1/packs", { action: "pack list" });
703
+ if (values.json) console.log(JSON.stringify(summaries ?? []));
704
+ else console.log(formatPackList(summaries ?? []));
705
+ return;
706
+ }
707
+ if (action === "remove") {
708
+ if (!target) {
709
+ throw usageError("Usage: ennote pack remove <publisher>/<name> [--force] [--json]");
710
+ }
711
+ const packId = parsePackID(target);
712
+ const endpoint = loadWorkerEndpoint(home);
713
+ const [publisher, name] = packId.split("/");
714
+ const query = values.force ? "?force=1" : "";
715
+ const pathname = `/v1/packs/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}${query}`;
716
+ // A 204 carries no body; the CLI still reports the same shape as --json so
717
+ // scripts do not have to special-case the empty response.
718
+ const data = (await requestJSON(endpoint, "DELETE", pathname, { action: "pack remove" })) ?? {
719
+ packId,
720
+ removed: true,
721
+ };
722
+ if (values.json) console.log(JSON.stringify(data));
723
+ else console.log(`Removed ${data.packId ?? packId}${values.force ? " (forced)" : ""}`);
724
+ return;
725
+ }
726
+ throw usageError(
727
+ `Unknown pack command: ${action ?? ""}\nUsage: ennote pack <inspect|install|list|remove> ...`,
728
+ );
729
+ }
730
+
731
+ // parsePackRenames turns repeated --as-flow/--as-role old=new flags into the
732
+ // renames object. A rename only changes the installed store id.
733
+ function parsePackRenames(entries, flag) {
734
+ const renames = {};
735
+ for (const entry of entries ?? []) {
736
+ const text = String(entry);
737
+ const separator = text.indexOf("=");
738
+ if (separator <= 0 || separator === text.length - 1) {
739
+ throw usageError(`${flag} expects old=new, got ${text}`);
740
+ }
741
+ const from = text.slice(0, separator).trim();
742
+ const to = text.slice(separator + 1).trim();
743
+ if (from === "" || to === "") throw usageError(`${flag} expects old=new, got ${text}`);
744
+ if (Object.prototype.hasOwnProperty.call(renames, from) && renames[from] !== to) {
745
+ throw usageError(`${flag} maps ${from} twice (${renames[from]} and ${to})`);
746
+ }
747
+ renames[from] = to;
748
+ }
749
+ return renames;
750
+ }
751
+
752
+ // parsePackID validates <publisher>/<name> before it goes on the wire: a bad id
753
+ // is a usage error, not a Worker 400/404.
754
+ function parsePackID(value) {
755
+ const segments = String(value).split("/");
756
+ if (segments.length !== 2 || segments.some(segment => segment === "" || segment === "." || segment === "..")) {
757
+ throw usageError(`pack id must be <publisher>/<name>, got ${value}`);
758
+ }
759
+ return segments.join("/");
760
+ }
761
+
762
+ async function requestPackInstall(endpoint, target, options = {}) {
763
+ const body = { source: { kind: "path", path: target }, dryRun: Boolean(options.dryRun) };
764
+ const renames = options.renames ?? {};
765
+ const encoded = {};
766
+ if (Object.keys(renames.flows ?? {}).length > 0) encoded.flows = renames.flows;
767
+ if (Object.keys(renames.roles ?? {}).length > 0) encoded.roles = renames.roles;
768
+ if (Object.keys(encoded).length > 0) body.renames = encoded;
769
+ return requestJSON(endpoint, "POST", "/v1/packs/install", { body, action: "pack install" });
770
+ }
771
+
772
+ function formatPackActionCounts(actions) {
773
+ const counts = new Map();
774
+ for (const action of actions) {
775
+ counts.set(action.action, (counts.get(action.action) ?? 0) + 1);
776
+ }
777
+ return [...counts.entries()]
778
+ .sort(([left], [right]) => left.localeCompare(right))
779
+ .map(([name, count]) => `${count} ${name}`)
780
+ .join(", ");
781
+ }
782
+
783
+ function formatPackInstallPlan(plan, options = {}) {
784
+ const pack = plan?.pack ?? {};
785
+ const lines = [];
786
+ const headline = plan?.reinstall === "noop"
787
+ ? "Already installed"
788
+ : options.dryRun ? "Dry run (nothing written)" : "Installed";
789
+ lines.push(`${headline}: ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
790
+ lines.push(`Manifest: ${plan?.manifestDigest ?? "?"}`);
791
+ lines.push(`Pack: ${plan?.packDigest ?? "?"}`);
792
+
793
+ const actions = plan?.actions ?? [];
794
+ lines.push(`Actions (${actions.length}): ${formatPackActionCounts(actions) || "none"}`);
795
+ for (const action of actions) {
796
+ const scope = action.scope ? ` (${action.scope})` : "";
797
+ lines.push(` - ${action.action} ${action.kind} ${action.storeId}${scope}`);
798
+ }
799
+
800
+ const state = plan?.state;
801
+ if (state) {
802
+ lines.push(`State: ${state.installState ?? "?"}; flows ${state.flows?.length ?? 0}, roles ${state.roles?.length ?? 0}, skills ${state.skills?.length ?? 0}`);
803
+ const drafts = (state.flows ?? []).filter(flow => !flow.publishedRevision);
804
+ if (drafts.length > 0) {
805
+ lines.push(`Disabled: ${drafts.map(flow => flow.storeId).join(", ")} (draft Graph, no published revision — publish it to enable runs)`);
806
+ }
807
+ if (state.source?.path) lines.push(`Source: ${state.source.path}`);
808
+ const external = state.externalRefs ?? {};
809
+ const externalSkills = external.skills ?? [];
810
+ const externalMcp = external.mcp ?? [];
811
+ if (externalSkills.length > 0 || externalMcp.length > 0) {
812
+ lines.push(`Recorded (not installed): skills ${externalSkills.join(", ") || "none"}; mcp ${externalMcp.join(", ") || "none"}`);
813
+ }
814
+ } else if (options.dryRun) {
815
+ lines.push("State: not written (dry run)");
816
+ }
817
+
818
+ const blockers = plan?.blockers ?? [];
819
+ lines.push(`Blockers (${blockers.length})`);
820
+ for (const blocker of blockers) {
821
+ lines.push(` - ${blocker.code} (${blocker.severity ?? "error"}): ${blocker.message}`);
822
+ }
823
+ const warnings = plan?.warnings ?? [];
824
+ lines.push(`Warnings (${warnings.length})`);
825
+ for (const warning of warnings) {
826
+ lines.push(` - ${warning.code} (${warning.severity ?? "warning"}): ${warning.message}`);
827
+ }
828
+ return lines.join("\n");
829
+ }
830
+
831
+ function formatPackList(summaries) {
832
+ if (summaries.length === 0) return "No Agent Packs installed.";
833
+ const lines = [`Installed Agent Packs (${summaries.length})`];
834
+ for (const summary of summaries) {
835
+ const pack = summary.pack ?? {};
836
+ lines.push(` - ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"}) ${summary.installState ?? "?"}`);
837
+ lines.push(` flows ${summary.flowCount ?? 0}, roles ${summary.roleCount ?? 0}, skills ${summary.skillCount ?? 0}; installed ${summary.installedAt ?? "?"}`);
838
+ }
839
+ return lines.join("\n");
363
840
  }
364
841
 
365
842
  async function setPassword(home, reset) {
@@ -397,11 +874,20 @@ Commands:
397
874
  logs Show recent service logs
398
875
  passwd Set the local password
399
876
  passwd --reset Remove the local password and require setup
877
+ pack inspect Inspect an Agent Pack (.enpack or directory) without installing
878
+ pack install Install an Agent Pack from a .enpack file or directory
879
+ pack list List installed Agent Packs
880
+ pack remove Uninstall an Agent Pack (<publisher>/<name>)
400
881
 
401
882
  Options:
402
883
  --port, -p Browser-facing port (default ${DEFAULT_PORT})
403
884
  --hostname, -H Bind address (default ${DEFAULT_HOSTNAME})
404
- --json Machine-readable output
885
+ --json Machine-readable output (prints the Worker data payload)
886
+ --documents pack inspect: include flow, role, and skill source text
887
+ --dry-run pack install: plan only and write nothing
888
+ --as-flow pack install: old=new rename of an installed Graph id
889
+ --as-role pack install: old=new rename of an installed Role handle
890
+ --force pack remove: override local edits and shared global Roles
405
891
  --version, -v Show version
406
892
  --help, -h Show this help`);
407
893
  }
@@ -415,6 +901,11 @@ async function main(argv = process.argv.slice(2)) {
415
901
  port: { type: "string", short: "p", default: DEFAULT_PORT },
416
902
  hostname: { type: "string", short: "H", default: DEFAULT_HOSTNAME },
417
903
  json: { type: "boolean", default: false },
904
+ documents: { type: "boolean", default: false },
905
+ "dry-run": { type: "boolean", default: false },
906
+ "as-flow": { type: "string", multiple: true },
907
+ "as-role": { type: "string", multiple: true },
908
+ force: { type: "boolean", default: false },
418
909
  reset: { type: "boolean", default: false },
419
910
  version: { type: "boolean", short: "v", default: false },
420
911
  help: { type: "boolean", short: "h", default: false },
@@ -451,6 +942,11 @@ async function main(argv = process.argv.slice(2)) {
451
942
  case "passwd":
452
943
  result = await setPassword(home, values.reset);
453
944
  break;
945
+ case "pack":
946
+ // Every pack command talks to the running Worker instead of re-implementing
947
+ // manifest parsing, installation, or store writes in Node.
948
+ await packCommand(home, positionals, values);
949
+ return;
454
950
  default:
455
951
  throw new Error(`Unknown command: ${command}`);
456
952
  }
@@ -466,6 +962,7 @@ module.exports = {
466
962
  runtimeBinDir,
467
963
  runtimePaths,
468
964
  statePaths,
965
+ workerStatePath,
469
966
  serviceURL,
470
967
  validateRuntime,
471
968
  healthCheck,
@@ -473,17 +970,31 @@ module.exports = {
473
970
  parseSHA256SUMS,
474
971
  fileSHA256,
475
972
  verifyFileSHA256,
973
+ compareVersions,
974
+ cachedBinaryVersions,
975
+ releasesBase,
476
976
  ensureBinaries,
477
977
  startService,
478
978
  stopService,
479
979
  serviceStatus,
480
980
  setPassword,
981
+ isLoopbackHostname,
982
+ loadWorkerEndpoint,
983
+ requestPackInspect,
984
+ formatPackSummary,
985
+ requestJSON,
986
+ requestPackInstall,
987
+ parsePackRenames,
988
+ parsePackID,
989
+ formatPackInstallPlan,
990
+ formatPackList,
991
+ packCommand,
481
992
  main,
482
993
  };
483
994
 
484
995
  if (require.main === module) {
485
996
  main().catch((error) => {
486
997
  console.error(error instanceof Error ? error.message : String(error));
487
- process.exitCode = 1;
998
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
488
999
  });
489
1000
  }