@agentage/cli 0.40.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.
- package/README.md +23 -8
- package/dist/commands/extension/build.d.ts +10 -0
- package/dist/commands/extension/build.js +27 -0
- package/dist/commands/extension/delegate.d.ts +4 -2
- package/dist/commands/extension/delegate.js +27 -15
- package/dist/commands/extension/doctor.d.ts +13 -0
- package/dist/commands/extension/{check.js → doctor.js} +14 -5
- package/dist/commands/extension/install-load.js +10 -1
- package/dist/commands/extension/install.js +4 -1
- package/dist/commands/extension/kit-commands.d.ts +13 -0
- package/dist/commands/extension/kit-commands.js +42 -0
- package/dist/commands/extension/kit-tool.d.ts +31 -0
- package/dist/commands/extension/kit-tool.js +5 -0
- package/dist/commands/extension/latest.js +3 -2
- package/dist/commands/extension/list.d.ts +8 -3
- package/dist/commands/extension/list.js +35 -21
- package/dist/commands/extension/rows.d.ts +2 -0
- package/dist/commands/extension/rows.js +15 -9
- package/dist/commands/extension/up.d.ts +22 -0
- package/dist/commands/extension/up.js +76 -0
- package/dist/core/installer/compat.d.ts +10 -2
- package/dist/core/installer/compat.js +13 -4
- package/dist/core/installer/latest.d.ts +1 -0
- package/dist/core/installer/latest.js +7 -5
- package/dist/core/installer/npm.js +1 -1
- package/dist/core/installer/resolve.d.ts +1 -0
- package/dist/core/installer/resolve.js +6 -5
- package/dist/core/kit-version.d.ts +1 -0
- package/dist/core/kit-version.js +15 -0
- package/dist/core/loader/discover.d.ts +4 -0
- package/dist/core/loader/discover.js +6 -2
- package/dist/core/loader/kit-manifest.d.ts +9 -0
- package/dist/core/loader/kit-manifest.js +52 -0
- package/dist/core/loader/load.d.ts +2 -1
- package/dist/core/loader/load.js +21 -4
- package/dist/core/loader/manifest.d.ts +7 -1
- package/dist/core/loader/manifest.js +37 -6
- package/dist/core/loader/verbs.d.ts +15 -0
- package/dist/core/loader/verbs.js +57 -0
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.js +1 -0
- package/dist/program.d.ts +10 -2
- package/dist/program.js +21 -5
- package/package.json +2 -1
- package/dist/commands/extension/check.d.ts +0 -10
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { InvalidArgumentError } from 'commander';
|
|
2
|
+
import { loadKitTool } from './kit-tool.js';
|
|
3
|
+
const print = (line) => console.log(line);
|
|
4
|
+
// Each step's line as the kit reports it, the ready line last.
|
|
5
|
+
export const runExtensionUp = async (opts = {}, tool = loadKitTool) => {
|
|
6
|
+
const { up } = await tool();
|
|
7
|
+
await up({ ...opts, onLine: print });
|
|
8
|
+
};
|
|
9
|
+
export const runExtensionDown = async (opts = {}, tool = loadKitTool) => {
|
|
10
|
+
const { down } = await tool();
|
|
11
|
+
await down({ ...opts, onLine: print });
|
|
12
|
+
};
|
|
13
|
+
// The token alone on stdout, so a script can take it as it is.
|
|
14
|
+
export const runExtensionToken = async (opts, tool = loadKitTool) => {
|
|
15
|
+
const { token } = await tool();
|
|
16
|
+
console.log(await token(opts));
|
|
17
|
+
};
|
|
18
|
+
// A usage error (exit 2) before the kit is asked: the issuer is an http: or https: URL.
|
|
19
|
+
export const issuerUrl = (value) => {
|
|
20
|
+
let protocol;
|
|
21
|
+
try {
|
|
22
|
+
protocol = new URL(value).protocol;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new InvalidArgumentError('not a URL.');
|
|
26
|
+
}
|
|
27
|
+
if (protocol === 'http:' || protocol === 'https:')
|
|
28
|
+
return value;
|
|
29
|
+
throw new InvalidArgumentError('not an http: or https: URL.');
|
|
30
|
+
};
|
|
31
|
+
const UP_HELP = `
|
|
32
|
+
Runs the extension package in --dir (default: the current folder) on this machine with Docker
|
|
33
|
+
only, the way a deployment runs it: the part images built with the release recipe, the store
|
|
34
|
+
with its migrations and seeds, a dev token issuer and a local gateway. Prints one line per step
|
|
35
|
+
as the kit reports it, the last one once every part is healthy. Needs docker. Exits 1 when a
|
|
36
|
+
step fails, with the kit's message.`;
|
|
37
|
+
const DOWN_HELP = `
|
|
38
|
+
Removes what agentage extension up started for the package in --dir: its containers, network
|
|
39
|
+
and the store's volume; --keep-data keeps the volume. Exits 1 when that fails.`;
|
|
40
|
+
const TOKEN_HELP = `
|
|
41
|
+
Prints a token from the dev issuer agentage extension up started, alone on stdout, for calling
|
|
42
|
+
/api/v1/ and /mcp by hand or from tests. Only up trusts it. Exits 1 when the issuer refuses.`;
|
|
43
|
+
const DIR = '--dir <path>';
|
|
44
|
+
const DIR_TEXT = 'the extension package folder (default: the current folder)';
|
|
45
|
+
const addUp = (group, up) => {
|
|
46
|
+
group
|
|
47
|
+
.command('up')
|
|
48
|
+
.description('Run the extension package on this machine: images, store, issuer, gateway')
|
|
49
|
+
.option(DIR, DIR_TEXT)
|
|
50
|
+
.addHelpText('after', UP_HELP)
|
|
51
|
+
.action(up);
|
|
52
|
+
};
|
|
53
|
+
const addDown = (group, down) => {
|
|
54
|
+
group
|
|
55
|
+
.command('down')
|
|
56
|
+
.description('Remove what extension up started; --keep-data keeps the store')
|
|
57
|
+
.option(DIR, DIR_TEXT)
|
|
58
|
+
.option('--keep-data', "keep the store's volume")
|
|
59
|
+
.addHelpText('after', DOWN_HELP)
|
|
60
|
+
.action(down);
|
|
61
|
+
};
|
|
62
|
+
const addToken = (group, token) => {
|
|
63
|
+
group
|
|
64
|
+
.command('token')
|
|
65
|
+
.description('Print a token from the dev issuer extension up started')
|
|
66
|
+
.requiredOption('--scope <scope>', 'the scope the token carries, e.g. probe:write')
|
|
67
|
+
.option('--issuer <url>', 'the dev issuer (default: http://localhost:4281)', issuerUrl)
|
|
68
|
+
.addHelpText('after', TOKEN_HELP)
|
|
69
|
+
.action(token);
|
|
70
|
+
};
|
|
71
|
+
// up, down and token: thin commands over the kit's tool library, for extension authors.
|
|
72
|
+
export const addKitTools = (group, actions) => {
|
|
73
|
+
addUp(group, actions.up);
|
|
74
|
+
addDown(group, actions.down);
|
|
75
|
+
addToken(group, actions.token);
|
|
76
|
+
};
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
export interface Published {
|
|
2
2
|
version: string;
|
|
3
3
|
engine: string;
|
|
4
|
+
kit?: boolean;
|
|
4
5
|
}
|
|
5
6
|
export interface Compatible {
|
|
6
7
|
version: string | null;
|
|
7
8
|
skipped: Published[];
|
|
8
9
|
}
|
|
10
|
+
export interface Gate {
|
|
11
|
+
api: string;
|
|
12
|
+
kit: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const cliGate: () => Gate;
|
|
9
15
|
export declare const fitsApi: (engine: string, api?: string) => boolean;
|
|
10
|
-
export declare const
|
|
11
|
-
export declare const
|
|
16
|
+
export declare const fits: (p: Omit<Published, "version">, gate?: Gate) => boolean;
|
|
17
|
+
export declare const needsText: ({ engine, kit }: Omit<Published, "version">) => string;
|
|
18
|
+
export declare const needs: (p: Published) => string;
|
|
19
|
+
export declare const newestCompatible: (published: Published[], gate?: Gate) => Compatible;
|
|
12
20
|
export declare const parsePublished: (stdout: string) => Published[] | null;
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { EXTENSION_API_VERSION } from '../../extension.js';
|
|
2
2
|
import { isRecord } from '../../lib/fs/json-file.js';
|
|
3
|
+
import { kitVersion } from '../kit-version.js';
|
|
3
4
|
import { manifestInfo } from '../loader/manifest.js';
|
|
4
5
|
import { isRange, newestFirst, satisfies } from '../semver-range.js';
|
|
6
|
+
export const cliGate = () => ({ api: EXTENSION_API_VERSION, kit: kitVersion() });
|
|
5
7
|
export const fitsApi = (engine, api = EXTENSION_API_VERSION) => isRange(engine) && satisfies(api, engine);
|
|
6
|
-
|
|
8
|
+
// The gate picks the API by manifest shape.
|
|
9
|
+
export const fits = (p, gate = cliGate()) => fitsApi(p.engine, p.kit ? gate.kit : gate.api);
|
|
10
|
+
export const needsText = ({ engine, kit }) => `needs ${kit ? 'kit' : 'extension API'} ${engine}`;
|
|
11
|
+
export const needs = (p) => `${p.version}: ${needsText(p)}`;
|
|
7
12
|
// The resolver's rule: the newest version this CLI can load, instead of whatever `latest` is.
|
|
8
|
-
export const newestCompatible = (published,
|
|
13
|
+
export const newestCompatible = (published, gate = cliGate()) => {
|
|
9
14
|
const sorted = [...published].sort((a, b) => newestFirst(a.version, b.version));
|
|
10
|
-
const at = sorted.findIndex((p) =>
|
|
15
|
+
const at = sorted.findIndex((p) => fits(p, gate));
|
|
11
16
|
if (at === -1)
|
|
12
17
|
return { version: null, skipped: sorted };
|
|
13
18
|
return { version: sorted[at]?.version ?? null, skipped: sorted.slice(0, at) };
|
|
14
19
|
};
|
|
20
|
+
// npm view names a nested field by its path: `agentage.parts`.
|
|
15
21
|
const publishedOf = (item) => {
|
|
16
22
|
if (typeof item === 'string')
|
|
17
23
|
return { version: item, engine: manifestInfo({}).engine };
|
|
18
24
|
if (!isRecord(item) || typeof item['version'] !== 'string')
|
|
19
25
|
return null;
|
|
20
|
-
|
|
26
|
+
const parts = item['agentage.parts'];
|
|
27
|
+
const info = manifestInfo(parts === undefined ? item : { ...item, agentage: { parts } });
|
|
28
|
+
const kit = info.kind === 'kit' ? { kit: true } : {};
|
|
29
|
+
return { version: item['version'], engine: info.engine, ...kit };
|
|
21
30
|
};
|
|
22
31
|
// npm view prints one object per version, a lone object for one version, or bare versions.
|
|
23
32
|
export const parsePublished = (stdout) => {
|
|
@@ -2,7 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { isRecord, readJson } from '../../lib/fs/json-file.js';
|
|
3
3
|
import { manifestInfo } from '../loader/manifest.js';
|
|
4
4
|
import { extensionsDir } from '../paths.js';
|
|
5
|
-
import {
|
|
5
|
+
import { fits } from './compat.js';
|
|
6
6
|
import { lockEntries, lockedVersion, parseLock, rootDependencies } from './compare-lock.js';
|
|
7
7
|
import { planInstall, refuseDrift, runNpmInto, } from './installer.js';
|
|
8
8
|
import {} from './npm.js';
|
|
@@ -26,14 +26,16 @@ const update = async (plan, npm, packages = []) => {
|
|
|
26
26
|
throw err;
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
|
-
const engineIn = (staging, name) =>
|
|
30
|
-
.
|
|
29
|
+
const engineIn = (staging, name) => {
|
|
30
|
+
const info = manifestInfo(readJson(join(staging, 'node_modules', name, 'package.json'), isRecord) ?? {});
|
|
31
|
+
return { engine: info.engine, kit: info.kind === 'kit' };
|
|
32
|
+
};
|
|
31
33
|
// An extension whose new version needs another extension API stays where it was.
|
|
32
34
|
const heldIn = (built, changes) => changes.flatMap(({ name, from, to }) => {
|
|
33
35
|
if (from === null || to === null)
|
|
34
36
|
return [];
|
|
35
|
-
const
|
|
36
|
-
return
|
|
37
|
+
const range = engineIn(built.staging, name);
|
|
38
|
+
return fits(range) ? [] : [{ name, version: from, newer: to, ...range }];
|
|
37
39
|
});
|
|
38
40
|
const unchanged = (plan, changes) => Object.keys(plan.install).filter((name) => !changes.some((c) => c.name === name)).length;
|
|
39
41
|
// One update; when it moved an extension past the extension API, again without moving that one.
|
|
@@ -7,7 +7,7 @@ const LOOKUP_TIMEOUT_MS = 2 * 60_000;
|
|
|
7
7
|
// `name` keeps every version an object: npm view unwraps to bare values when the first has one field.
|
|
8
8
|
const LOOKUP_FLAGS = {
|
|
9
9
|
pack: ['--dry-run', '--json'],
|
|
10
|
-
view: ['name', 'version', 'engines', '--json'],
|
|
10
|
+
view: ['name', 'version', 'engines', 'agentage.parts', '--json'],
|
|
11
11
|
};
|
|
12
12
|
// npm reads its env config case-insensitively, so either spelling may carry these paths.
|
|
13
13
|
const npmConfig = (env, key) => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
-
import { EXTENSION_API_VERSION } from '../../extension.js';
|
|
4
3
|
import { isRecord, readJson } from '../../lib/fs/json-file.js';
|
|
5
4
|
import {} from '../../lib/npm/npm-cli.js';
|
|
6
5
|
import { PACKAGE_NAME, specProblem } from '../profile/schema.js';
|
|
7
|
-
import { needs, newestCompatible, parsePublished } from './compat.js';
|
|
6
|
+
import { cliGate, needs, newestCompatible, parsePublished } from './compat.js';
|
|
7
|
+
import { isKitShaped } from '../loader/kit-manifest.js';
|
|
8
8
|
import { lastLines } from './installer.js';
|
|
9
9
|
import {} from './npm.js';
|
|
10
10
|
// Every release: a range, not `*`, which npm view reads as the `latest` tag alone.
|
|
@@ -41,7 +41,7 @@ const devExtension = (input) => {
|
|
|
41
41
|
const name = manifest?.['name'];
|
|
42
42
|
if (path === null || typeof name !== 'string' || !isRecord(agentage) || !agentage['id'])
|
|
43
43
|
throw new Error(`${input}: not an extension folder (no package.json with a name and agentage.id)`);
|
|
44
|
-
return { kind: 'dev', name, path };
|
|
44
|
+
return { kind: 'dev', name, path, kit: isKitShaped(agentage) };
|
|
45
45
|
};
|
|
46
46
|
// The first candidate the registry has; a missing one moves on to the next.
|
|
47
47
|
const firstFound = async (specs, input, look) => {
|
|
@@ -60,11 +60,12 @@ const fromUrl = async (url, look) => {
|
|
|
60
60
|
return { kind: 'package', name, spec: url, skipped: [] };
|
|
61
61
|
};
|
|
62
62
|
const pinned = (input, name, published) => {
|
|
63
|
-
const
|
|
63
|
+
const gate = cliGate();
|
|
64
|
+
const { version, skipped } = newestCompatible(published, gate);
|
|
64
65
|
if (version !== null)
|
|
65
66
|
return { kind: 'package', name, spec: `^${version}`, skipped };
|
|
66
67
|
const newest = skipped[0] === undefined ? '' : ` (${needs(skipped[0])})`;
|
|
67
|
-
const api = `extension API ${
|
|
68
|
+
const api = skipped[0]?.kit ? `kit ${gate.kit}` : `extension API ${gate.api}`;
|
|
68
69
|
throw new Error(`${input}: no published version works with ${api}${newest}`);
|
|
69
70
|
};
|
|
70
71
|
// The first candidate the registry has, at its newest version this CLI can load.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const kitVersion: () => string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { isRecord, readJson } from '../lib/fs/json-file.js';
|
|
3
|
+
let cached;
|
|
4
|
+
const read = () => {
|
|
5
|
+
try {
|
|
6
|
+
const path = createRequire(import.meta.url).resolve('@agentage/extension/package.json');
|
|
7
|
+
const version = readJson(path, isRecord)?.['version'];
|
|
8
|
+
return typeof version === 'string' ? version : '0.0.0';
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return '0.0.0';
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
// The kit's own version (KIT_VERSION), read as data so startup never loads the kit's code.
|
|
15
|
+
export const kitVersion = () => (cached ??= read());
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { type ConfigMap } from '../profile/schema.js';
|
|
2
|
+
import { type KitCommand } from './kit-manifest.js';
|
|
3
|
+
import { type ManifestKind } from './manifest.js';
|
|
2
4
|
export type ExtensionSource = 'tree' | 'dev';
|
|
3
5
|
export interface DiscoveredExtension {
|
|
4
6
|
pkg: string;
|
|
7
|
+
kind: ManifestKind;
|
|
5
8
|
source: ExtensionSource;
|
|
6
9
|
dir: string;
|
|
7
10
|
version: string | null;
|
|
8
11
|
id: string | null;
|
|
9
12
|
entry: string | null;
|
|
10
13
|
commands: string[];
|
|
14
|
+
kitCommands: KitCommand[];
|
|
11
15
|
peers: string[];
|
|
12
16
|
optionalPeers: string[];
|
|
13
17
|
config: Record<string, unknown>;
|
|
@@ -4,6 +4,7 @@ import { isRecord } from '../../lib/fs/json-file.js';
|
|
|
4
4
|
import {} from '../profile/schema.js';
|
|
5
5
|
import { checkConfig } from './config.js';
|
|
6
6
|
import { unsafeDevPath } from './dev-path.js';
|
|
7
|
+
import {} from './kit-manifest.js';
|
|
7
8
|
import { manifestInfo, manifestProblem } from './manifest.js';
|
|
8
9
|
const readText = (path) => {
|
|
9
10
|
try {
|
|
@@ -50,12 +51,14 @@ const entryProblem = (info, path) => {
|
|
|
50
51
|
};
|
|
51
52
|
const blank = (pkg, source, dir) => ({
|
|
52
53
|
pkg,
|
|
54
|
+
kind: 'api',
|
|
53
55
|
source,
|
|
54
56
|
dir,
|
|
55
57
|
version: null,
|
|
56
58
|
id: null,
|
|
57
59
|
entry: null,
|
|
58
60
|
commands: [],
|
|
61
|
+
kitCommands: [],
|
|
59
62
|
peers: [],
|
|
60
63
|
optionalPeers: [],
|
|
61
64
|
config: {},
|
|
@@ -73,12 +76,13 @@ const inspect = ({ pkg, source, dir }, configs) => {
|
|
|
73
76
|
return { ...base, missing: true };
|
|
74
77
|
const info = manifestInfo(manifest);
|
|
75
78
|
const entry = info.entry === null ? null : inside(dir, info.entry);
|
|
76
|
-
const { version, id, commands, peers, optionalPeers } = info;
|
|
79
|
+
const { kind, version, id, commands, kitCommands, peers, optionalPeers } = info;
|
|
77
80
|
const slice = configs[id ?? ''] ?? {};
|
|
78
81
|
const { problem: badConfig, ...declared } = checkConfig(id ?? '', info.configuration, slice);
|
|
79
82
|
const unsafe = source === 'dev' ? unsafeDevPath(dir, process.getuid?.()) : null;
|
|
80
83
|
const problem = unsafe ?? manifestProblem(pkg, info) ?? entryProblem(info, entry) ?? badConfig;
|
|
81
|
-
|
|
84
|
+
const read = { kind, version, id, entry, commands, kitCommands, peers, optionalPeers };
|
|
85
|
+
return { ...base, ...read, ...declared, problem };
|
|
82
86
|
};
|
|
83
87
|
// Every claimant of a shared id is invalid; a dev copy of the same package never reaches here.
|
|
84
88
|
const markDuplicates = (extensions) => extensions.map((p) => {
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type DiscoveredExtension } from './discover.js';
|
|
3
3
|
import { type LoadedExtension } from './register.js';
|
|
4
|
+
export type KitCheck = (dir: string) => Promise<string | null>;
|
|
4
5
|
export interface LoadOutcome {
|
|
5
6
|
state: 'ok' | 'failed' | 'blocked';
|
|
6
7
|
error: string | null;
|
|
@@ -10,4 +11,4 @@ export interface LoadSession {
|
|
|
10
11
|
loaded: Map<string, LoadedExtension>;
|
|
11
12
|
dispose: () => Promise<void>;
|
|
12
13
|
}
|
|
13
|
-
export declare const loadExtensions: (extensions: DiscoveredExtension[], template?: Command) => Promise<LoadSession>;
|
|
14
|
+
export declare const loadExtensions: (extensions: DiscoveredExtension[], template?: Command, check?: KitCheck) => Promise<LoadSession>;
|
package/dist/core/loader/load.js
CHANGED
|
@@ -2,18 +2,35 @@ import { Command } from 'commander';
|
|
|
2
2
|
import {} from './discover.js';
|
|
3
3
|
import { disposeAll, holdSignals } from './dispose.js';
|
|
4
4
|
import { loadOrder } from './order.js';
|
|
5
|
-
import { registerExtension } from './register.js';
|
|
5
|
+
import { registerExtension, registerTimeoutMs } from './register.js';
|
|
6
6
|
import { writeRunState } from './state.js';
|
|
7
|
+
import { withTimeout } from './timeout.js';
|
|
7
8
|
import { createUse } from './use.js';
|
|
9
|
+
const noKitCheck = async () => 'no kit check in this command';
|
|
8
10
|
const blockedBy = (ext, outcomes) => {
|
|
9
11
|
const required = ext.peers.filter((peer) => !ext.optionalPeers.includes(peer));
|
|
10
12
|
const peer = required.find((name) => outcomes.get(name)?.state !== 'ok');
|
|
11
13
|
return peer === undefined ? null : `peer ${peer} is ${outcomes.get(peer)?.state ?? 'not loaded'}`;
|
|
12
14
|
};
|
|
13
|
-
const
|
|
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) => {
|
|
14
29
|
const blocked = blockedBy(ext, session.outcomes);
|
|
15
30
|
if (blocked)
|
|
16
31
|
return { state: 'blocked', error: blocked };
|
|
32
|
+
if (ext.kind === 'kit')
|
|
33
|
+
return checkKit(ext, check);
|
|
17
34
|
const result = await registerExtension(ext, createUse(ext, session.loaded), template);
|
|
18
35
|
if (!result.ok)
|
|
19
36
|
return { state: 'failed', error: result.error };
|
|
@@ -46,13 +63,13 @@ const newSession = () => {
|
|
|
46
63
|
return { outcomes: new Map(), loaded, dispose };
|
|
47
64
|
};
|
|
48
65
|
// Loads in peer order; one extension failing never stops the others, only what depends on it.
|
|
49
|
-
export const loadExtensions = async (extensions, template = new Command('agentage')) => {
|
|
66
|
+
export const loadExtensions = async (extensions, template = new Command('agentage'), check = noKitCheck) => {
|
|
50
67
|
// Not a sandbox, but the token never reaches extension code through the environment.
|
|
51
68
|
delete process.env['AGENTAGE_TOKEN'];
|
|
52
69
|
const session = newSession();
|
|
53
70
|
await toStderr(async () => {
|
|
54
71
|
for (const ext of loadOrder(extensions)) {
|
|
55
|
-
const outcome = await attempt(ext, session, template);
|
|
72
|
+
const outcome = await attempt(ext, session, template, check);
|
|
56
73
|
session.outcomes.set(ext.pkg, outcome);
|
|
57
74
|
writeRunState(ext.id ?? '', { ...outcome, version: ext.version });
|
|
58
75
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { type KitCommand } from './kit-manifest.js';
|
|
1
2
|
export declare const ID_PATTERN: RegExp;
|
|
3
|
+
export declare const CORE_COMMANDS: string[];
|
|
4
|
+
export type ManifestKind = 'api' | 'kit';
|
|
2
5
|
export interface ManifestInfo {
|
|
6
|
+
kind: ManifestKind;
|
|
3
7
|
name: string | null;
|
|
4
8
|
version: string | null;
|
|
5
9
|
id: string | null;
|
|
@@ -9,6 +13,8 @@ export interface ManifestInfo {
|
|
|
9
13
|
peers: string[];
|
|
10
14
|
optionalPeers: string[];
|
|
11
15
|
configuration: Record<string, unknown>;
|
|
16
|
+
kitCommands: KitCommand[];
|
|
17
|
+
shape: string | null;
|
|
12
18
|
}
|
|
13
19
|
export declare const manifestInfo: (pkg: Record<string, unknown>) => ManifestInfo;
|
|
14
|
-
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,9 +1,11 @@
|
|
|
1
1
|
import { isRecord } from '../../lib/fs/json-file.js';
|
|
2
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
7
|
const DEFAULT_ENGINE = '^1';
|
|
6
|
-
const CORE_COMMANDS = ['setup', 'status', 'update', 'extension', 'add', 'remove', 'help'];
|
|
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
10
|
const RESERVED_IDS = new Set([...CORE_COMMANDS, ...DEVICE_NAMES]);
|
|
9
11
|
const SUBCOMMAND = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -30,9 +32,29 @@ const entryOf = (pkg) => {
|
|
|
30
32
|
const optionalPeersOf = (meta) => Object.entries(meta)
|
|
31
33
|
.filter(([, m]) => isRecord(m) && m['optional'] === true)
|
|
32
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
|
+
};
|
|
33
52
|
export const manifestInfo = (pkg) => {
|
|
34
53
|
const agentage = record(pkg['agentage']);
|
|
54
|
+
if (isKitShaped(agentage))
|
|
55
|
+
return kitInfo(pkg, agentage);
|
|
35
56
|
return {
|
|
57
|
+
kind: 'api',
|
|
36
58
|
name: str(pkg['name']),
|
|
37
59
|
version: str(pkg['version']),
|
|
38
60
|
id: str(agentage['id']),
|
|
@@ -42,6 +64,8 @@ export const manifestInfo = (pkg) => {
|
|
|
42
64
|
peers: Object.keys(record(pkg['peerDependencies'])),
|
|
43
65
|
optionalPeers: optionalPeersOf(record(pkg['peerDependenciesMeta'])),
|
|
44
66
|
configuration: record(agentage['configuration']),
|
|
67
|
+
kitCommands: [],
|
|
68
|
+
shape: null,
|
|
45
69
|
};
|
|
46
70
|
};
|
|
47
71
|
const idProblem = (id) => {
|
|
@@ -51,13 +75,18 @@ const idProblem = (id) => {
|
|
|
51
75
|
return `agentage.id "${id}" must match [a-z][a-z0-9-]*`;
|
|
52
76
|
return RESERVED_IDS.has(id) ? `agentage.id "${id}" is reserved` : null;
|
|
53
77
|
};
|
|
54
|
-
|
|
78
|
+
// `against` names what the range must match: the extension API, or the kit.
|
|
79
|
+
const engineProblem = (engine, version, against) => {
|
|
55
80
|
if (!isRange(engine))
|
|
56
81
|
return `engines.agentage "${engine}" is not a supported range`;
|
|
57
|
-
if (satisfies(
|
|
82
|
+
if (satisfies(version, engine))
|
|
58
83
|
return null;
|
|
59
|
-
return `engines.agentage "${engine}" does not match
|
|
84
|
+
return `engines.agentage "${engine}" does not match ${against} ${version}`;
|
|
60
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'));
|
|
61
90
|
// Commands are named after the id, so no two extensions, nor the core, can claim one.
|
|
62
91
|
const commandsProblem = (id, commands) => {
|
|
63
92
|
const ownSub = (name) => name.startsWith(`${id}:`) && SUBCOMMAND.test(name.slice(id.length + 1));
|
|
@@ -67,11 +96,13 @@ const commandsProblem = (id, commands) => {
|
|
|
67
96
|
return `agentage.contributes.commands: "${bad}" must be "${id}" or "${id}:<name>"`;
|
|
68
97
|
};
|
|
69
98
|
// Static checks only: the manifest is read as data and the entry is never imported.
|
|
70
|
-
export const manifestProblem = (pkg, info, api = EXTENSION_API_VERSION) => {
|
|
99
|
+
export const manifestProblem = (pkg, info, api = EXTENSION_API_VERSION, kit = kitVersion()) => {
|
|
71
100
|
if (info.name !== pkg)
|
|
72
101
|
return `package.json name ${JSON.stringify(info.name)} is not ${pkg}`;
|
|
102
|
+
if (info.kind === 'kit')
|
|
103
|
+
return kitProblem(info, kit);
|
|
73
104
|
return (idProblem(info.id) ??
|
|
74
|
-
engineProblem(info.engine, api) ??
|
|
105
|
+
engineProblem(info.engine, api, 'extension API') ??
|
|
75
106
|
(info.entry === null ? 'no entry point (exports or main)' : null) ??
|
|
76
107
|
commandsProblem(info.id ?? '', info.commands));
|
|
77
108
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
2
|
+
export interface Namespaced {
|
|
3
|
+
pkg: string;
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
why: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Verbs {
|
|
9
|
+
top: Map<string, string>;
|
|
10
|
+
namespaced: Namespaced[];
|
|
11
|
+
}
|
|
12
|
+
export declare const readHeld: () => Map<string, string>;
|
|
13
|
+
export declare const writeHeld: (top: Map<string, string>) => void;
|
|
14
|
+
export declare const claimVerbs: (extensions: DiscoveredExtension[], held: Map<string, string>) => Verbs;
|
|
15
|
+
export declare const commandNames: (ext: DiscoveredExtension, verbs: Verbs) => string[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { isRecord, readJson, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
5
|
+
import { commandsPath } from '../paths.js';
|
|
6
|
+
import {} from './discover.js';
|
|
7
|
+
import { CORE_COMMANDS } from './manifest.js';
|
|
8
|
+
const isHeld = (value) => isRecord(value) && Object.values(value).every((pkg) => typeof pkg === 'string');
|
|
9
|
+
// Who held each top-level verb after the last install; absent or unreadable: nobody.
|
|
10
|
+
export const readHeld = () => new Map(Object.entries(readJson(commandsPath(), isHeld) ?? {}));
|
|
11
|
+
// Written only when it changed, so a machine without kit commands never gets the file.
|
|
12
|
+
export const writeHeld = (top) => {
|
|
13
|
+
const next = Object.fromEntries(top);
|
|
14
|
+
const current = Object.fromEntries(readHeld());
|
|
15
|
+
if (isDeepStrictEqual(next, current))
|
|
16
|
+
return;
|
|
17
|
+
mkdirSync(dirname(commandsPath()), { recursive: true, mode: 0o700 });
|
|
18
|
+
writeJsonAtomic(commandsPath(), next);
|
|
19
|
+
};
|
|
20
|
+
// Core commands and every extension id are namespaces; 1.0 commands are named after their id.
|
|
21
|
+
const takenNames = (extensions) => {
|
|
22
|
+
const taken = new Map(CORE_COMMANDS.map((name) => [name, 'is a core command']));
|
|
23
|
+
for (const ext of extensions.filter((e) => e.id !== null)) {
|
|
24
|
+
const why = `is taken by extension ${ext.id}`;
|
|
25
|
+
taken.set(ext.id ?? '', why);
|
|
26
|
+
if (ext.kind === 'api')
|
|
27
|
+
for (const name of ext.commands)
|
|
28
|
+
taken.set(name, why);
|
|
29
|
+
}
|
|
30
|
+
return taken;
|
|
31
|
+
};
|
|
32
|
+
const whyNot = (name, taken, top, ids) => taken.get(name) ?? `is taken by extension ${ids.get(top.get(name) ?? '') ?? '?'}`;
|
|
33
|
+
const namespacedOf = (kit, taken, top) => {
|
|
34
|
+
const ids = new Map(kit.map((ext) => [ext.pkg, ext.id ?? '']));
|
|
35
|
+
return kit.flatMap((ext) => ext.commands
|
|
36
|
+
.filter((name) => top.get(name) !== ext.pkg)
|
|
37
|
+
.map((name) => ({ pkg: ext.pkg, id: ext.id ?? '', name, why: whyNot(name, taken, top, ids) })));
|
|
38
|
+
};
|
|
39
|
+
// An earlier holder keeps its verb; a later claimant runs only as `agentage {id} {name}`.
|
|
40
|
+
export const claimVerbs = (extensions, held) => {
|
|
41
|
+
const kit = extensions.filter((ext) => ext.kind === 'kit' && ext.id !== null);
|
|
42
|
+
const taken = takenNames(extensions);
|
|
43
|
+
const top = new Map();
|
|
44
|
+
const claim = (name, pkg) => {
|
|
45
|
+
if (!taken.has(name) && !top.has(name))
|
|
46
|
+
top.set(name, pkg);
|
|
47
|
+
};
|
|
48
|
+
for (const [name, pkg] of held)
|
|
49
|
+
if (kit.some((ext) => ext.pkg === pkg && ext.commands.includes(name)))
|
|
50
|
+
claim(name, pkg);
|
|
51
|
+
for (const ext of kit)
|
|
52
|
+
for (const name of ext.commands)
|
|
53
|
+
claim(name, ext.pkg);
|
|
54
|
+
return { top, namespaced: namespacedOf(kit, taken, top) };
|
|
55
|
+
};
|
|
56
|
+
// What `extension list` shows: a namespaced command as `{id} {name}`, any other as it is.
|
|
57
|
+
export const commandNames = (ext, verbs) => ext.commands.map((name) => verbs.namespaced.some((n) => n.pkg === ext.pkg && n.name === name) ? `${ext.id} ${name}` : name);
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const profilePath: () => string;
|
|
2
2
|
export declare const machinePath: () => string;
|
|
3
3
|
export declare const stateDir: () => string;
|
|
4
|
+
export declare const commandsPath: () => string;
|
|
4
5
|
export declare const extensionsDir: () => string;
|
|
5
6
|
export declare const secretsPath: () => string;
|
|
6
7
|
export declare const dataDir: (id: string) => string;
|
package/dist/core/paths.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getConfigDir } from '../lib/fs/config.js';
|
|
|
3
3
|
export const profilePath = () => join(getConfigDir(), 'profile.json');
|
|
4
4
|
export const machinePath = () => join(getConfigDir(), 'machine.json');
|
|
5
5
|
export const stateDir = () => join(getConfigDir(), 'state');
|
|
6
|
+
export const commandsPath = () => join(getConfigDir(), 'commands.json');
|
|
6
7
|
export const extensionsDir = () => process.env['AGENTAGE_EXTENSIONS_DIR'] || join(getConfigDir(), 'extensions');
|
|
7
8
|
export const secretsPath = () => join(getConfigDir(), 'secrets.json');
|
|
8
9
|
export const dataDir = (id) => join(getConfigDir(), 'data', id);
|