@agentage/cli 0.39.0 → 0.41.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 (145) hide show
  1. package/README.md +126 -25
  2. package/dist/commands/auth/setup.d.ts +8 -2
  3. package/dist/commands/auth/setup.js +38 -7
  4. package/dist/commands/extension/account.d.ts +5 -0
  5. package/dist/commands/extension/account.js +18 -0
  6. package/dist/commands/extension/build.d.ts +10 -0
  7. package/dist/commands/extension/build.js +27 -0
  8. package/dist/commands/extension/delegate.d.ts +6 -0
  9. package/dist/commands/extension/delegate.js +57 -0
  10. package/dist/commands/extension/doctor.d.ts +13 -0
  11. package/dist/commands/extension/doctor.js +44 -0
  12. package/dist/commands/extension/install-load.d.ts +7 -0
  13. package/dist/commands/extension/install-load.js +42 -0
  14. package/dist/commands/extension/install.d.ts +10 -0
  15. package/dist/commands/extension/install.js +38 -0
  16. package/dist/commands/extension/kit-commands.d.ts +13 -0
  17. package/dist/commands/extension/kit-commands.js +42 -0
  18. package/dist/commands/extension/kit-tool.d.ts +31 -0
  19. package/dist/commands/extension/kit-tool.js +5 -0
  20. package/dist/commands/extension/latest.d.ts +2 -0
  21. package/dist/commands/extension/latest.js +71 -0
  22. package/dist/commands/extension/list.d.ts +22 -0
  23. package/dist/commands/extension/list.js +116 -0
  24. package/dist/commands/extension/plan.d.ts +8 -0
  25. package/dist/commands/extension/plan.js +67 -0
  26. package/dist/commands/extension/remove.d.ts +15 -0
  27. package/dist/commands/extension/remove.js +86 -0
  28. package/dist/commands/extension/row-lines.d.ts +4 -0
  29. package/dist/commands/extension/row-lines.js +68 -0
  30. package/dist/commands/extension/rows.d.ts +58 -0
  31. package/dist/commands/extension/rows.js +67 -0
  32. package/dist/commands/extension/secrets-prompt.d.ts +6 -0
  33. package/dist/commands/extension/secrets-prompt.js +47 -0
  34. package/dist/commands/extension/setup-cloud.d.ts +6 -0
  35. package/dist/commands/extension/setup-cloud.js +98 -0
  36. package/dist/commands/extension/setup.d.ts +5 -0
  37. package/dist/commands/extension/setup.js +68 -0
  38. package/dist/commands/extension/sync.d.ts +22 -0
  39. package/dist/commands/extension/sync.js +81 -0
  40. package/dist/commands/extension/up.d.ts +22 -0
  41. package/dist/commands/extension/up.js +76 -0
  42. package/dist/commands/status/account-status.d.ts +3 -0
  43. package/dist/commands/status/account-status.js +27 -0
  44. package/dist/commands/status/status.d.ts +3 -2
  45. package/dist/commands/status/status.js +36 -36
  46. package/dist/core/installer/compare-lock.d.ts +8 -0
  47. package/dist/core/installer/compare-lock.js +51 -0
  48. package/dist/core/installer/compat.d.ts +20 -0
  49. package/dist/core/installer/compat.js +44 -0
  50. package/dist/core/installer/installer.d.ts +28 -0
  51. package/dist/core/installer/installer.js +87 -0
  52. package/dist/core/installer/latest.d.ts +27 -0
  53. package/dist/core/installer/latest.js +90 -0
  54. package/dist/core/installer/npm.d.ts +11 -0
  55. package/dist/core/installer/npm.js +76 -0
  56. package/dist/core/installer/plan.d.ts +25 -0
  57. package/dist/core/installer/plan.js +52 -0
  58. package/dist/core/installer/resolve.d.ts +26 -0
  59. package/dist/core/installer/resolve.js +140 -0
  60. package/dist/core/installer/tree.d.ts +21 -0
  61. package/dist/core/installer/tree.js +100 -0
  62. package/dist/core/kit-version.d.ts +1 -0
  63. package/dist/core/kit-version.js +15 -0
  64. package/dist/core/loader/config.d.ts +7 -0
  65. package/dist/core/loader/config.js +59 -0
  66. package/dist/core/loader/ctx.d.ts +16 -0
  67. package/dist/core/loader/ctx.js +76 -0
  68. package/dist/core/loader/dev-path.d.ts +1 -0
  69. package/dist/core/loader/dev-path.js +20 -0
  70. package/dist/core/loader/discover.d.ts +17 -11
  71. package/dist/core/loader/discover.js +50 -31
  72. package/dist/core/loader/dispose.d.ts +4 -0
  73. package/dist/core/loader/dispose.js +24 -0
  74. package/dist/core/loader/kit-manifest.d.ts +9 -0
  75. package/dist/core/loader/kit-manifest.js +52 -0
  76. package/dist/core/loader/load.d.ts +14 -0
  77. package/dist/core/loader/load.js +78 -0
  78. package/dist/core/loader/log.d.ts +3 -0
  79. package/dist/core/loader/log.js +23 -0
  80. package/dist/core/loader/manifest.d.ts +8 -4
  81. package/dist/core/loader/manifest.js +54 -12
  82. package/dist/core/loader/order.d.ts +2 -0
  83. package/dist/core/loader/order.js +20 -0
  84. package/dist/core/loader/register.d.ts +19 -0
  85. package/dist/core/loader/register.js +39 -0
  86. package/dist/core/loader/scan.d.ts +18 -0
  87. package/dist/core/loader/scan.js +34 -0
  88. package/dist/core/loader/secrets.d.ts +9 -0
  89. package/dist/core/loader/secrets.js +28 -0
  90. package/dist/core/loader/state.d.ts +4 -4
  91. package/dist/core/loader/state.js +17 -7
  92. package/dist/core/loader/states.d.ts +2 -2
  93. package/dist/core/loader/states.js +5 -5
  94. package/dist/core/loader/timeout.d.ts +1 -0
  95. package/dist/core/loader/timeout.js +8 -0
  96. package/dist/core/loader/use.d.ts +8 -0
  97. package/dist/core/loader/use.js +18 -0
  98. package/dist/core/loader/verbs.d.ts +15 -0
  99. package/dist/core/loader/verbs.js +57 -0
  100. package/dist/core/paths.d.ts +6 -1
  101. package/dist/core/paths.js +6 -1
  102. package/dist/core/profile/cloud.d.ts +53 -0
  103. package/dist/core/profile/cloud.js +109 -0
  104. package/dist/core/profile/edit.d.ts +4 -0
  105. package/dist/core/profile/edit.js +27 -0
  106. package/dist/core/profile/machine.d.ts +11 -11
  107. package/dist/core/profile/machine.js +66 -43
  108. package/dist/core/profile/merge.d.ts +1 -1
  109. package/dist/core/profile/merge.js +7 -2
  110. package/dist/core/profile/profile.d.ts +6 -6
  111. package/dist/core/profile/profile.js +50 -16
  112. package/dist/core/profile/push.d.ts +16 -0
  113. package/dist/core/profile/push.js +49 -0
  114. package/dist/core/profile/register.d.ts +5 -0
  115. package/dist/core/profile/register.js +57 -0
  116. package/dist/core/profile/schema.d.ts +3 -1
  117. package/dist/core/profile/schema.js +39 -17
  118. package/dist/core/semver-range.d.ts +1 -0
  119. package/dist/core/semver-range.js +8 -0
  120. package/dist/extension-test.d.ts +7 -0
  121. package/dist/extension-test.js +49 -0
  122. package/dist/extension.d.ts +83 -0
  123. package/dist/extension.js +6 -0
  124. package/dist/lib/auth/api.d.ts +2 -2
  125. package/dist/lib/auth/api.js +3 -3
  126. package/dist/lib/fs/file-lock.d.ts +3 -1
  127. package/dist/lib/fs/file-lock.js +26 -11
  128. package/dist/lib/fs/json-file.d.ts +1 -0
  129. package/dist/lib/fs/json-file.js +3 -2
  130. package/dist/lib/net/fetch.js +1 -1
  131. package/dist/lib/net/origins.d.ts +1 -0
  132. package/dist/lib/net/origins.js +11 -11
  133. package/dist/lib/npm/npm-cli.d.ts +2 -0
  134. package/dist/lib/npm/npm-cli.js +4 -4
  135. package/dist/program.d.ts +17 -1
  136. package/dist/program.js +61 -11
  137. package/package.json +9 -4
  138. package/dist/commands/plugins/status-print.d.ts +0 -2
  139. package/dist/commands/plugins/status-print.js +0 -44
  140. package/dist/commands/plugins/status-rows.d.ts +0 -51
  141. package/dist/commands/plugins/status-rows.js +0 -58
  142. package/dist/core/flag.d.ts +0 -1
  143. package/dist/core/flag.js +0 -1
  144. package/dist/plugin.d.ts +0 -1
  145. package/dist/plugin.js +0 -2
@@ -1,6 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { isRecord } from '../../lib/fs/json-file.js';
4
+ import {} from '../profile/schema.js';
5
+ import { checkConfig } from './config.js';
6
+ import { unsafeDevPath } from './dev-path.js';
7
+ import {} from './kit-manifest.js';
4
8
  import { manifestInfo, manifestProblem } from './manifest.js';
5
9
  const readText = (path) => {
6
10
  try {
@@ -10,10 +14,9 @@ const readText = (path) => {
10
14
  return null;
11
15
  }
12
16
  };
13
- const defaultDeps = { readText, exists: existsSync };
14
17
  const TREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
15
- const readManifest = (dir, deps) => {
16
- const raw = deps.readText(join(dir, 'package.json'));
18
+ const readManifest = (dir) => {
19
+ const raw = readText(join(dir, 'package.json'));
17
20
  try {
18
21
  const value = raw === null ? null : JSON.parse(raw);
19
22
  return isRecord(value) ? value : null;
@@ -22,59 +25,68 @@ const readManifest = (dir, deps) => {
22
25
  return null;
23
26
  }
24
27
  };
25
- // `plugins/current` names the tree; one without its `.complete` marker was never finished.
26
- export const currentTree = (pluginsDir, deps = defaultDeps) => {
27
- const name = deps.readText(join(pluginsDir, 'current'))?.trim() ?? '';
28
+ // `extensions/current` names the tree; one without its `.complete` marker was never finished.
29
+ export const currentTree = (extensionsDir) => {
30
+ const name = readText(join(extensionsDir, 'current'))?.trim() ?? '';
28
31
  if (name === '')
29
32
  return { dir: null, warning: null };
30
33
  if (!TREE_NAME.test(name))
31
- return { dir: null, warning: `plugins/current: bad tree "${name}"` };
32
- const dir = join(pluginsDir, 'trees', name);
33
- if (deps.exists(join(dir, '.complete')))
34
+ return { dir: null, warning: `extensions/current: bad tree "${name}"` };
35
+ const dir = join(extensionsDir, 'trees', name);
36
+ if (existsSync(join(dir, '.complete')))
34
37
  return { dir, warning: null };
35
- return { dir: null, warning: `plugins tree "${name}" is incomplete and was ignored` };
38
+ return { dir: null, warning: `extensions tree "${name}" is incomplete and was ignored` };
36
39
  };
37
40
  const inside = (dir, entry) => {
38
41
  const path = resolve(dir, entry);
39
42
  const rel = relative(dir, path);
40
43
  return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? null : path;
41
44
  };
42
- const entryProblem = (info, path, deps) => {
45
+ const entryProblem = (info, path) => {
43
46
  if (info.entry === null)
44
47
  return null;
45
48
  if (path === null)
46
49
  return `entry ${info.entry} is outside the package`;
47
- return deps.exists(path) ? null : `entry ${info.entry} not found`;
50
+ return existsSync(path) ? null : `entry ${info.entry} not found`;
48
51
  };
49
52
  const blank = (pkg, source, dir) => ({
50
53
  pkg,
54
+ kind: 'api',
51
55
  source,
52
56
  dir,
53
57
  version: null,
54
58
  id: null,
55
59
  entry: null,
56
60
  commands: [],
61
+ kitCommands: [],
57
62
  peers: [],
58
63
  optionalPeers: [],
64
+ config: {},
65
+ secrets: [],
66
+ descriptions: {},
59
67
  problem: null,
60
68
  missing: false,
61
69
  });
62
- const inspect = (pkg, source, dir, deps) => {
70
+ const inspect = ({ pkg, source, dir }, configs) => {
63
71
  const base = blank(pkg, source, dir ?? '');
64
72
  if (source === 'dev' && !isAbsolute(base.dir))
65
73
  return { ...base, problem: 'dev path not absolute' };
66
- const manifest = dir === null ? null : readManifest(dir, deps);
74
+ const manifest = dir === null ? null : readManifest(dir);
67
75
  if (dir === null || manifest === null)
68
76
  return { ...base, missing: true };
69
77
  const info = manifestInfo(manifest);
70
78
  const entry = info.entry === null ? null : inside(dir, info.entry);
71
- const problem = manifestProblem(pkg, info) ?? entryProblem(info, entry, deps);
72
- const { version, id, commands, peers, optionalPeers } = info;
73
- return { ...base, version, id, entry, commands, peers, optionalPeers, problem };
79
+ const { kind, version, id, commands, kitCommands, peers, optionalPeers } = info;
80
+ const slice = configs[id ?? ''] ?? {};
81
+ const { problem: badConfig, ...declared } = checkConfig(id ?? '', info.configuration, slice);
82
+ const unsafe = source === 'dev' ? unsafeDevPath(dir, process.getuid?.()) : null;
83
+ const problem = unsafe ?? manifestProblem(pkg, info) ?? entryProblem(info, entry) ?? badConfig;
84
+ const read = { kind, version, id, entry, commands, kitCommands, peers, optionalPeers };
85
+ return { ...base, ...read, ...declared, problem };
74
86
  };
75
87
  // Every claimant of a shared id is invalid; a dev copy of the same package never reaches here.
76
- const markDuplicates = (plugins) => plugins.map((p) => {
77
- const others = plugins.filter((o) => o !== p && p.id !== null && o.id === p.id);
88
+ const markDuplicates = (extensions) => extensions.map((p) => {
89
+ const others = extensions.filter((o) => o !== p && p.id !== null && o.id === p.id);
78
90
  if (others.length === 0 || p.problem)
79
91
  return p;
80
92
  return {
@@ -82,22 +94,29 @@ const markDuplicates = (plugins) => plugins.map((p) => {
82
94
  problem: `duplicate id "${p.id}" (also ${others.map((o) => o.pkg).join(', ')})`,
83
95
  };
84
96
  });
85
- // A peer counts as a plugin unless the tree holds it as a plain library without `agentage`.
86
- const pluginPeers = (p, known, modules, deps) => p.peers.filter((peer) => {
87
- if (known.has(peer) || modules === null)
97
+ // A peer counts as an extension unless the tree holds it as a plain library without `agentage`.
98
+ const extensionPeers = (p, known, nodeModules) => p.peers.filter((peer) => {
99
+ if (known.has(peer) || nodeModules === null)
88
100
  return true;
89
- const manifest = readManifest(join(modules, peer), deps);
101
+ const manifest = readManifest(join(nodeModules, peer));
90
102
  return manifest === null || isRecord(manifest['agentage']);
91
103
  });
92
- export const discover = (input, deps = defaultDeps) => {
93
- const tree = currentTree(input.pluginsDir, deps);
94
- const modules = tree.dir === null ? null : join(tree.dir, 'node_modules');
104
+ export const discover = (input) => {
105
+ const tree = input.tree ? { dir: input.tree, warning: null } : currentTree(input.extensionsDir);
106
+ const nodeModules = tree.dir === null ? null : join(tree.dir, 'node_modules');
95
107
  const installed = input.names.filter((name) => !(name in input.dev));
96
108
  const found = markDuplicates([
97
- ...installed.map((name) => inspect(name, 'tree', modules && join(modules, name), deps)),
98
- ...Object.entries(input.dev).map(([name, path]) => inspect(name, 'dev', path, deps)),
99
- ]);
109
+ ...installed.map((pkg) => ({
110
+ pkg,
111
+ source: 'tree',
112
+ dir: nodeModules && join(nodeModules, pkg),
113
+ })),
114
+ ...Object.entries(input.dev).map(([pkg, dir]) => ({ pkg, source: 'dev', dir })),
115
+ ].map((candidate) => inspect(candidate, input.config)));
100
116
  const known = new Set(found.map((p) => p.pkg));
101
- const plugins = found.map((p) => ({ ...p, peers: pluginPeers(p, known, modules, deps) }));
102
- return { plugins, warnings: tree.warning === null ? [] : [tree.warning] };
117
+ const extensions = found.map((p) => ({
118
+ ...p,
119
+ peers: extensionPeers(p, known, nodeModules),
120
+ }));
121
+ return { extensions, warnings: tree.warning === null ? [] : [tree.warning] };
103
122
  };
@@ -0,0 +1,4 @@
1
+ import { type Disposable } from '../../extension.js';
2
+ export declare const DISPOSE_TIMEOUT_MS = 5000;
3
+ export declare const disposeAll: (id: string, subscriptions: Disposable[]) => Promise<void>;
4
+ export declare const holdSignals: (dispose: () => Promise<void>) => (() => void);
@@ -0,0 +1,24 @@
1
+ import {} from '../../extension.js';
2
+ import { extensionLog } from './log.js';
3
+ import { withTimeout } from './timeout.js';
4
+ export const DISPOSE_TIMEOUT_MS = 5_000;
5
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
6
+ // Each subscription gets its 5 s at once; one that hangs is abandoned, and every result is logged.
7
+ export const disposeAll = async (id, subscriptions) => {
8
+ const log = extensionLog(id);
9
+ await Promise.all(subscriptions.map(async (subscription, index) => {
10
+ const disposing = Promise.resolve().then(() => subscription.dispose());
11
+ const result = await withTimeout(disposing, DISPOSE_TIMEOUT_MS).then(() => 'done', messageOf);
12
+ log('dispose', { subscription: index, result });
13
+ }));
14
+ };
15
+ const SIGNALS = ['SIGINT', 'SIGTERM'];
16
+ // The signal waits for disposal, then is raised again with no handler: the exit it would have had.
17
+ export const holdSignals = (dispose) => {
18
+ const handlers = SIGNALS.map((signal) => {
19
+ const handler = () => void dispose().finally(() => process.kill(process.pid, signal));
20
+ process.once(signal, handler);
21
+ return { signal, handler };
22
+ });
23
+ return () => handlers.forEach(({ signal, handler }) => process.off(signal, handler));
24
+ };
@@ -0,0 +1,9 @@
1
+ export interface KitCommand {
2
+ name: string;
3
+ summary: string;
4
+ }
5
+ export declare const DEFAULT_CLI_ENTRY = "./dist/cli/index.js";
6
+ export declare const isKitShaped: (agentage: Record<string, unknown>) => boolean;
7
+ export declare const cliEntryOf: (parts: unknown) => string | null;
8
+ export declare const kitCommandsOf: (value: unknown) => KitCommand[];
9
+ export declare const kitShapeProblem: (agentage: Record<string, unknown>) => string | null;
@@ -0,0 +1,52 @@
1
+ import { isRecord } from '../../lib/fs/json-file.js';
2
+ export const DEFAULT_CLI_ENTRY = './dist/cli/index.js';
3
+ const COMMAND = /^[a-z0-9][a-z0-9-]*$/;
4
+ // A manifest with agentage.parts is built on the extension kit and is never register-loaded.
5
+ export const isKitShaped = (agentage) => agentage['parts'] !== undefined;
6
+ // parts.cli is true or { entry }; without an entry it is the kit's build output.
7
+ export const cliEntryOf = (parts) => {
8
+ const cli = isRecord(parts) ? parts['cli'] : undefined;
9
+ if (cli === true)
10
+ return DEFAULT_CLI_ENTRY;
11
+ if (!isRecord(cli))
12
+ return null;
13
+ return typeof cli['entry'] === 'string' ? cli['entry'] : DEFAULT_CLI_ENTRY;
14
+ };
15
+ const text = (value) => (typeof value === 'string' ? value : '');
16
+ export const kitCommandsOf = (value) => (Array.isArray(value) ? value : [])
17
+ .filter(isRecord)
18
+ .map((command) => ({ name: text(command['name']), summary: text(command['summary']) }));
19
+ const commandProblem = (value, seen) => {
20
+ const name = isRecord(value) ? value['name'] : undefined;
21
+ if (typeof name !== 'string' || !COMMAND.test(name))
22
+ return `agentage.commands: ${JSON.stringify(name ?? null)} must match [a-z0-9][a-z0-9-]*`;
23
+ if (seen.has(name))
24
+ return `agentage.commands: "${name}" is declared twice`;
25
+ seen.add(name);
26
+ return isRecord(value) && typeof value['summary'] === 'string'
27
+ ? null
28
+ : `agentage.commands: "${name}" has no summary`;
29
+ };
30
+ const commandsProblem = (commands) => {
31
+ const seen = new Set();
32
+ for (const command of commands) {
33
+ const problem = commandProblem(command, seen);
34
+ if (problem)
35
+ return problem;
36
+ }
37
+ return null;
38
+ };
39
+ // What reading the kit-shaped block as data can tell; the kit's own schema checks the rest.
40
+ export const kitShapeProblem = (agentage) => {
41
+ const { parts, commands = [] } = agentage;
42
+ if (!isRecord(parts))
43
+ return 'agentage.parts must be an object';
44
+ if (!Array.isArray(commands))
45
+ return 'agentage.commands must be a list';
46
+ const problem = commandsProblem(commands);
47
+ if (problem)
48
+ return problem;
49
+ return commands.length > 0 && cliEntryOf(parts) === null
50
+ ? 'agentage.commands needs parts.cli'
51
+ : null;
52
+ };
@@ -0,0 +1,14 @@
1
+ import { Command } from 'commander';
2
+ import { type DiscoveredExtension } from './discover.js';
3
+ import { type LoadedExtension } from './register.js';
4
+ export type KitCheck = (dir: string) => Promise<string | null>;
5
+ export interface LoadOutcome {
6
+ state: 'ok' | 'failed' | 'blocked';
7
+ error: string | null;
8
+ }
9
+ export interface LoadSession {
10
+ outcomes: Map<string, LoadOutcome>;
11
+ loaded: Map<string, LoadedExtension>;
12
+ dispose: () => Promise<void>;
13
+ }
14
+ export declare const loadExtensions: (extensions: DiscoveredExtension[], template?: Command, check?: KitCheck) => Promise<LoadSession>;
@@ -0,0 +1,78 @@
1
+ import { Command } from 'commander';
2
+ import {} from './discover.js';
3
+ import { disposeAll, holdSignals } from './dispose.js';
4
+ import { loadOrder } from './order.js';
5
+ import { registerExtension, registerTimeoutMs } from './register.js';
6
+ import { writeRunState } from './state.js';
7
+ import { withTimeout } from './timeout.js';
8
+ import { createUse } from './use.js';
9
+ const noKitCheck = async () => 'no kit check in this command';
10
+ const blockedBy = (ext, outcomes) => {
11
+ const required = ext.peers.filter((peer) => !ext.optionalPeers.includes(peer));
12
+ const peer = required.find((name) => outcomes.get(name)?.state !== 'ok');
13
+ return peer === undefined ? null : `peer ${peer} is ${outcomes.get(peer)?.state ?? 'not loaded'}`;
14
+ };
15
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
16
+ // The kit imports the entry, so its top-level code counts toward the same timeout as register.
17
+ const checkKit = async (ext, check) => {
18
+ if (ext.entry === null)
19
+ return { state: 'ok', error: null };
20
+ try {
21
+ const problem = await withTimeout(check(ext.dir), registerTimeoutMs());
22
+ return problem === null ? { state: 'ok', error: null } : { state: 'failed', error: problem };
23
+ }
24
+ catch (err) {
25
+ return { state: 'failed', error: messageOf(err) };
26
+ }
27
+ };
28
+ const attempt = async (ext, session, template, check) => {
29
+ const blocked = blockedBy(ext, session.outcomes);
30
+ if (blocked)
31
+ return { state: 'blocked', error: blocked };
32
+ if (ext.kind === 'kit')
33
+ return checkKit(ext, check);
34
+ const result = await registerExtension(ext, createUse(ext, session.loaded), template);
35
+ if (!result.ok)
36
+ return { state: 'failed', error: result.error };
37
+ session.loaded.set(ext.id ?? '', result.loaded);
38
+ return { state: 'ok', error: null };
39
+ };
40
+ // What an extension prints while it loads goes to stderr, so stdout stays the command's own.
41
+ const toStderr = async (fn) => {
42
+ const saved = { log: console.log, info: console.info, debug: console.debug };
43
+ console.log = console.info = console.debug = console.error;
44
+ try {
45
+ await fn();
46
+ }
47
+ finally {
48
+ Object.assign(console, saved);
49
+ }
50
+ };
51
+ const newSession = () => {
52
+ const loaded = new Map();
53
+ let disposed;
54
+ const disposeOnce = async () => {
55
+ disposed ??= Promise.all([...loaded.values()].map((l) => disposeAll(l.ext.id ?? '', l.subscriptions)));
56
+ await disposed;
57
+ };
58
+ const release = holdSignals(disposeOnce);
59
+ const dispose = () => {
60
+ release();
61
+ return disposeOnce();
62
+ };
63
+ return { outcomes: new Map(), loaded, dispose };
64
+ };
65
+ // Loads in peer order; one extension failing never stops the others, only what depends on it.
66
+ export const loadExtensions = async (extensions, template = new Command('agentage'), check = noKitCheck) => {
67
+ // Not a sandbox, but the token never reaches extension code through the environment.
68
+ delete process.env['AGENTAGE_TOKEN'];
69
+ const session = newSession();
70
+ await toStderr(async () => {
71
+ for (const ext of loadOrder(extensions)) {
72
+ const outcome = await attempt(ext, session, template, check);
73
+ session.outcomes.set(ext.pkg, outcome);
74
+ writeRunState(ext.id ?? '', { ...outcome, version: ext.version });
75
+ }
76
+ });
77
+ return session;
78
+ };
@@ -0,0 +1,3 @@
1
+ import { type ExtensionLog } from '../../extension.js';
2
+ export declare const LOG_CAP_BYTES: number;
3
+ export declare const extensionLog: (id: string) => ExtensionLog;
@@ -0,0 +1,23 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {} from '../../extension.js';
4
+ import { writeTextAtomic } from '../../lib/fs/json-file.js';
5
+ import { logsDir } from '../paths.js';
6
+ export const LOG_CAP_BYTES = 1024 * 1024;
7
+ // Past the cap, keep the newest half from its first whole line, so the file stays small.
8
+ const trim = (path) => {
9
+ if (statSync(path).size <= LOG_CAP_BYTES)
10
+ return;
11
+ const tail = readFileSync(path)
12
+ .subarray(-LOG_CAP_BYTES / 2)
13
+ .toString('utf-8');
14
+ writeTextAtomic(path, tail.slice(tail.indexOf('\n') + 1));
15
+ };
16
+ // The extension's own log; the core never reads it, its run state lives in state/<id>.json.
17
+ export const extensionLog = (id) => (message, fields = {}) => {
18
+ const path = join(logsDir(), `${id}.log`);
19
+ mkdirSync(logsDir(), { recursive: true, mode: 0o700 });
20
+ const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
21
+ appendFileSync(path, `${line}\n`, { mode: 0o600 });
22
+ trim(path);
23
+ };
@@ -1,7 +1,9 @@
1
+ import { type KitCommand } from './kit-manifest.js';
1
2
  export declare const ID_PATTERN: RegExp;
2
- export declare const DEFAULT_ENGINE = "^1";
3
- export declare const RESERVED_IDS: ReadonlySet<string>;
3
+ export declare const CORE_COMMANDS: string[];
4
+ export type ManifestKind = 'api' | 'kit';
4
5
  export interface ManifestInfo {
6
+ kind: ManifestKind;
5
7
  name: string | null;
6
8
  version: string | null;
7
9
  id: string | null;
@@ -10,7 +12,9 @@ export interface ManifestInfo {
10
12
  commands: string[];
11
13
  peers: string[];
12
14
  optionalPeers: string[];
15
+ configuration: Record<string, unknown>;
16
+ kitCommands: KitCommand[];
17
+ shape: string | null;
13
18
  }
14
- export declare const entryOf: (pkg: Record<string, unknown>) => string | null;
15
19
  export declare const manifestInfo: (pkg: Record<string, unknown>) => ManifestInfo;
16
- export declare const manifestProblem: (pkg: string, info: ManifestInfo, api?: string) => string | null;
20
+ export declare const manifestProblem: (pkg: string, info: ManifestInfo, api?: string, kit?: string) => string | null;
@@ -1,11 +1,14 @@
1
1
  import { isRecord } from '../../lib/fs/json-file.js';
2
- import { PLUGIN_API_VERSION } from '../../plugin.js';
2
+ import { EXTENSION_API_VERSION } from '../../extension.js';
3
+ import { kitVersion } from '../kit-version.js';
3
4
  import { isRange, satisfies } from '../semver-range.js';
5
+ import { cliEntryOf, isKitShaped, kitCommandsOf, kitShapeProblem, } from './kit-manifest.js';
4
6
  export const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
5
- export const DEFAULT_ENGINE = '^1';
6
- const CORE_COMMANDS = ['setup', 'status', 'update', 'add', 'remove', 'help'];
7
+ const DEFAULT_ENGINE = '^1';
8
+ export const CORE_COMMANDS = ['setup', 'status', 'update', 'extension', 'add', 'remove', 'help'];
7
9
  const DEVICE_NAMES = ['con', 'prn', 'aux', 'nul'].concat(...Array.from({ length: 10 }, (_, n) => [`com${n}`, `lpt${n}`]));
8
- export const RESERVED_IDS = new Set([...CORE_COMMANDS, ...DEVICE_NAMES]);
10
+ const RESERVED_IDS = new Set([...CORE_COMMANDS, ...DEVICE_NAMES]);
11
+ const SUBCOMMAND = /^[a-z0-9][a-z0-9-]*$/;
9
12
  const str = (value) => (typeof value === 'string' ? value : null);
10
13
  const record = (value) => (isRecord(value) ? value : {});
11
14
  const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
@@ -17,7 +20,7 @@ const fromConditions = (value, depth = 0) => {
17
20
  return fromConditions(value['import'], depth + 1) ?? fromConditions(value['default'], depth + 1);
18
21
  };
19
22
  // Node semantics: a present `exports` field is authoritative, `main` only applies without it.
20
- export const entryOf = (pkg) => {
23
+ const entryOf = (pkg) => {
21
24
  const exp = pkg['exports'];
22
25
  if (exp === undefined)
23
26
  return str(pkg['main']);
@@ -29,17 +32,40 @@ export const entryOf = (pkg) => {
29
32
  const optionalPeersOf = (meta) => Object.entries(meta)
30
33
  .filter(([, m]) => isRecord(m) && m['optional'] === true)
31
34
  .map(([name]) => name);
35
+ const kitInfo = (pkg, agentage) => {
36
+ const kitCommands = kitCommandsOf(agentage['commands']);
37
+ return {
38
+ kind: 'kit',
39
+ name: str(pkg['name']),
40
+ version: str(pkg['version']),
41
+ id: str(agentage['id']),
42
+ engine: str(record(pkg['engines'])['agentage']) ?? '',
43
+ entry: cliEntryOf(agentage['parts']),
44
+ commands: kitCommands.map((command) => command.name),
45
+ peers: [],
46
+ optionalPeers: [],
47
+ configuration: {},
48
+ kitCommands,
49
+ shape: kitShapeProblem(agentage),
50
+ };
51
+ };
32
52
  export const manifestInfo = (pkg) => {
33
53
  const agentage = record(pkg['agentage']);
54
+ if (isKitShaped(agentage))
55
+ return kitInfo(pkg, agentage);
34
56
  return {
57
+ kind: 'api',
35
58
  name: str(pkg['name']),
36
59
  version: str(pkg['version']),
37
60
  id: str(agentage['id']),
38
61
  engine: str(record(pkg['engines'])['agentage']) ?? DEFAULT_ENGINE,
39
62
  entry: entryOf(pkg),
40
- commands: strings(record(agentage['contributes'])['commands']),
63
+ commands: [...new Set(strings(record(agentage['contributes'])['commands']))],
41
64
  peers: Object.keys(record(pkg['peerDependencies'])),
42
65
  optionalPeers: optionalPeersOf(record(pkg['peerDependenciesMeta'])),
66
+ configuration: record(agentage['configuration']),
67
+ kitCommands: [],
68
+ shape: null,
43
69
  };
44
70
  };
45
71
  const idProblem = (id) => {
@@ -49,18 +75,34 @@ const idProblem = (id) => {
49
75
  return `agentage.id "${id}" must match [a-z][a-z0-9-]*`;
50
76
  return RESERVED_IDS.has(id) ? `agentage.id "${id}" is reserved` : null;
51
77
  };
52
- const engineProblem = (engine, api) => {
78
+ // `against` names what the range must match: the extension API, or the kit.
79
+ const engineProblem = (engine, version, against) => {
53
80
  if (!isRange(engine))
54
81
  return `engines.agentage "${engine}" is not a supported range`;
55
- if (satisfies(api, engine))
82
+ if (satisfies(version, engine))
83
+ return null;
84
+ return `engines.agentage "${engine}" does not match ${against} ${version}`;
85
+ };
86
+ // A kit-shaped range is over the kit's version; it has no default.
87
+ const kitProblem = (info, kit) => idProblem(info.id) ??
88
+ info.shape ??
89
+ (info.engine === '' ? 'engines.agentage is missing' : engineProblem(info.engine, kit, 'kit'));
90
+ // Commands are named after the id, so no two extensions, nor the core, can claim one.
91
+ const commandsProblem = (id, commands) => {
92
+ const ownSub = (name) => name.startsWith(`${id}:`) && SUBCOMMAND.test(name.slice(id.length + 1));
93
+ const bad = commands.find((name) => name !== id && !ownSub(name));
94
+ if (bad === undefined)
56
95
  return null;
57
- return `engines.agentage "${engine}" does not match plugin API ${api}`;
96
+ return `agentage.contributes.commands: "${bad}" must be "${id}" or "${id}:<name>"`;
58
97
  };
59
98
  // Static checks only: the manifest is read as data and the entry is never imported.
60
- export const manifestProblem = (pkg, info, api = PLUGIN_API_VERSION) => {
99
+ export const manifestProblem = (pkg, info, api = EXTENSION_API_VERSION, kit = kitVersion()) => {
61
100
  if (info.name !== pkg)
62
101
  return `package.json name ${JSON.stringify(info.name)} is not ${pkg}`;
102
+ if (info.kind === 'kit')
103
+ return kitProblem(info, kit);
63
104
  return (idProblem(info.id) ??
64
- engineProblem(info.engine, api) ??
65
- (info.entry === null ? 'no entry point (exports or main)' : null));
105
+ engineProblem(info.engine, api, 'extension API') ??
106
+ (info.entry === null ? 'no entry point (exports or main)' : null) ??
107
+ commandsProblem(info.id ?? '', info.commands));
66
108
  };
@@ -0,0 +1,2 @@
1
+ import { type DiscoveredExtension } from './discover.js';
2
+ export declare const loadOrder: (roots: DiscoveredExtension[], all?: DiscoveredExtension[]) => DiscoveredExtension[];
@@ -0,0 +1,20 @@
1
+ import {} from './discover.js';
2
+ // Peers first, from `roots` through `all`; cycles are invalid already, `seen` ends the walk anyway.
3
+ export const loadOrder = (roots, all = roots) => {
4
+ const byPkg = new Map(all.map((ext) => [ext.pkg, ext]));
5
+ const seen = new Set();
6
+ const ordered = [];
7
+ const visit = (ext) => {
8
+ if (seen.has(ext.pkg))
9
+ return;
10
+ seen.add(ext.pkg);
11
+ for (const peer of [...ext.peers, ...ext.optionalPeers]) {
12
+ const found = byPkg.get(peer);
13
+ if (found)
14
+ visit(found);
15
+ }
16
+ ordered.push(ext);
17
+ };
18
+ roots.forEach(visit);
19
+ return ordered;
20
+ };
@@ -0,0 +1,19 @@
1
+ import { type Command } from 'commander';
2
+ import { type Disposable, type ExtensionApi, type ExtensionUse } from '../../extension.js';
3
+ import { type DiscoveredExtension } from './discover.js';
4
+ export declare const DEFAULT_REGISTER_TIMEOUT_MS = 10000;
5
+ export interface LoadedExtension {
6
+ ext: DiscoveredExtension;
7
+ program: Command;
8
+ subscriptions: Disposable[];
9
+ facade: ExtensionApi;
10
+ }
11
+ export type Attempt = {
12
+ ok: true;
13
+ loaded: LoadedExtension;
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export declare const registerTimeoutMs: () => number;
19
+ export declare const registerExtension: (ext: DiscoveredExtension, use: ExtensionUse, template: Command) => Promise<Attempt>;
@@ -0,0 +1,39 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import {} from 'commander';
3
+ import {} from '../../extension.js';
4
+ import { isRecord } from '../../lib/fs/json-file.js';
5
+ import { createContext } from './ctx.js';
6
+ import {} from './discover.js';
7
+ import { disposeAll } from './dispose.js';
8
+ import { withTimeout } from './timeout.js';
9
+ import { facadeOf } from './use.js';
10
+ export const DEFAULT_REGISTER_TIMEOUT_MS = 10_000;
11
+ // Tests shorten the wait; anything but a positive integer keeps the default.
12
+ export const registerTimeoutMs = () => {
13
+ const raw = process.env['AGENTAGE_REGISTER_TIMEOUT_MS'] ?? '';
14
+ return /^[1-9]\d{0,8}$/.test(raw) ? Number(raw) : DEFAULT_REGISTER_TIMEOUT_MS;
15
+ };
16
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
17
+ const isRegister = (value) => typeof value === 'function';
18
+ const importAndRegister = async (entry, ctx) => {
19
+ const exported = await import(pathToFileURL(entry).href);
20
+ const register = isRecord(exported) ? exported['register'] : undefined;
21
+ if (!isRegister(register))
22
+ throw new Error('the entry exports no register function');
23
+ return register(ctx);
24
+ };
25
+ // The import counts toward the timeout too: a top-level await can hang like register can.
26
+ export const registerExtension = async (ext, use, template) => {
27
+ const staged = createContext({ ext, use, template });
28
+ try {
29
+ const api = await withTimeout(importAndRegister(ext.entry ?? '', staged.ctx), registerTimeoutMs());
30
+ staged.close();
31
+ const { program, subscriptions } = staged;
32
+ return { ok: true, loaded: { ext, program, subscriptions, facade: facadeOf(api) } };
33
+ }
34
+ catch (err) {
35
+ staged.revoke();
36
+ await disposeAll(ext.id ?? '', staged.subscriptions);
37
+ return { ok: false, error: messageOf(err) };
38
+ }
39
+ };
@@ -0,0 +1,18 @@
1
+ import { type Plan } from '../installer/plan.js';
2
+ import { type MachineFile } from '../profile/machine.js';
3
+ import { type Effective } from '../profile/merge.js';
4
+ import { type ProfileParse } from '../profile/schema.js';
5
+ import { type DiscoveredExtension, type Discovery } from './discover.js';
6
+ import { type StaticResult } from './states.js';
7
+ export interface Scan {
8
+ machine: MachineFile;
9
+ parsed: ProfileParse | null;
10
+ eff: Effective;
11
+ found: Discovery;
12
+ states: Map<string, StaticResult>;
13
+ plan: Plan;
14
+ removed: DiscoveredExtension[];
15
+ drift: string[];
16
+ }
17
+ export declare const scan: (machine: MachineFile, tree?: string) => Scan;
18
+ export declare const loadSet: (found: Scan) => DiscoveredExtension[];
@@ -0,0 +1,34 @@
1
+ import { currentDependencies, lockDrift, planChanges } from '../installer/plan.js';
2
+ import {} from '../profile/machine.js';
3
+ import { effective } from '../profile/merge.js';
4
+ import { readProfile } from '../profile/profile.js';
5
+ import {} from '../profile/schema.js';
6
+ import { extensionsDir } from '../paths.js';
7
+ import { discover } from './discover.js';
8
+ import { staticStates } from './states.js';
9
+ // A tree extension whose bytes the two locks disagree on is never trusted, so never imported.
10
+ const withDrift = (found, drift) => ({
11
+ ...found,
12
+ extensions: found.extensions.map((p) => {
13
+ const key = `${p.pkg}@${p.version}`;
14
+ const drifted = p.source === 'tree' && p.problem === null && drift.includes(key);
15
+ return drifted ? { ...p, problem: `integrity drift: ${key}` } : p;
16
+ }),
17
+ });
18
+ // Everything known about this machine's extensions without running any of them; `tree` reads
19
+ // that tree instead of the current one.
20
+ export const scan = (machine, tree) => {
21
+ const parsed = readProfile();
22
+ const eff = effective(parsed?.ok ? parsed.profile : null, machine);
23
+ const dir = extensionsDir();
24
+ const drift = lockDrift(dir).integrity;
25
+ const input = { extensionsDir: dir, dev: eff.dev, config: eff.config, tree };
26
+ const found = withDrift(discover({ ...input, names: Object.keys(eff.install) }), drift);
27
+ const plan = planChanges(eff, currentDependencies(dir));
28
+ const gone = plan.extensions.filter((e) => e.verb === 'remove' && !(e.name in eff.dev));
29
+ const removed = discover({ ...input, dev: {}, names: gone.map((e) => e.name) }).extensions;
30
+ const states = staticStates(found.extensions, eff.off);
31
+ return { machine, parsed, eff, found, states, plan, removed, drift };
32
+ };
33
+ // What can load: a valid manifest, installed, not off here, and peers that can load too.
34
+ export const loadSet = (found) => found.found.extensions.filter((ext) => found.states.get(ext.pkg)?.state === 'installed');