@amalgm/tools 0.1.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 (52) hide show
  1. package/PURPOSE.md +87 -0
  2. package/README.md +62 -0
  3. package/dist/api-driver.d.ts +3 -0
  4. package/dist/api-driver.js +92 -0
  5. package/dist/artifact-files.d.ts +28 -0
  6. package/dist/artifact-files.js +115 -0
  7. package/dist/artifacts.d.ts +66 -0
  8. package/dist/artifacts.js +112 -0
  9. package/dist/bin/mcp.d.ts +2 -0
  10. package/dist/bin/mcp.js +13 -0
  11. package/dist/bin/tools.d.ts +2 -0
  12. package/dist/bin/tools.js +3 -0
  13. package/dist/cli-driver.d.ts +3 -0
  14. package/dist/cli-driver.js +52 -0
  15. package/dist/cli.d.ts +13 -0
  16. package/dist/cli.js +123 -0
  17. package/dist/definition.d.ts +7 -0
  18. package/dist/definition.js +170 -0
  19. package/dist/ids.d.ts +7 -0
  20. package/dist/ids.js +38 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.js +7 -0
  23. package/dist/input.d.ts +4 -0
  24. package/dist/input.js +43 -0
  25. package/dist/mcp-server.d.ts +18 -0
  26. package/dist/mcp-server.js +61 -0
  27. package/dist/mcp.d.ts +9 -0
  28. package/dist/mcp.js +95 -0
  29. package/dist/module.d.ts +3 -0
  30. package/dist/module.js +22 -0
  31. package/dist/process.d.ts +22 -0
  32. package/dist/process.js +46 -0
  33. package/dist/query.d.ts +23 -0
  34. package/dist/query.js +36 -0
  35. package/dist/results.d.ts +6 -0
  36. package/dist/results.js +27 -0
  37. package/dist/schema.d.ts +3 -0
  38. package/dist/schema.js +26 -0
  39. package/dist/secrets.d.ts +3 -0
  40. package/dist/secrets.js +13 -0
  41. package/dist/selection.d.ts +10 -0
  42. package/dist/selection.js +18 -0
  43. package/dist/store.d.ts +17 -0
  44. package/dist/store.js +96 -0
  45. package/dist/toolbox.d.ts +46 -0
  46. package/dist/toolbox.js +204 -0
  47. package/dist/types.d.ts +172 -0
  48. package/dist/types.js +1 -0
  49. package/dist/updates.d.ts +7 -0
  50. package/dist/updates.js +57 -0
  51. package/docs/ENGINE_INTEGRATION.md +57 -0
  52. package/package.json +57 -0
package/PURPOSE.md ADDED
@@ -0,0 +1,87 @@
1
+ # Amalgm Tools
2
+
3
+ ## Purpose
4
+
5
+ Amalgm Tools lets a user define, keep, select, and run agent capabilities
6
+ without installing Amalgm Engine. It serves people building agents,
7
+ automations, and applications that need one small local source of truth for
8
+ CLI, HTTP API, and MCP tools. When the product is healthy, every callable
9
+ action has one stable identity and one owning tool, a selected loadout exposes
10
+ only its granted capabilities, and the SDK, CLI, and MCP surfaces all observe
11
+ and execute the same catalog.
12
+
13
+ Amalgm Engine is the composition root for the complete Amalgm runtime. This
14
+ repository owns the Tools product and its Toolbox. Engine may provide
15
+ first-party actions, credentials, agent loadouts, state events, and UI
16
+ composition through public adapters, but it does not maintain a second tool
17
+ registry or execution implementation.
18
+
19
+ ## Primitives
20
+
21
+ - A **tool** is a stable provider or connector identity and its execution
22
+ source.
23
+ - An **action** is one model-callable capability owned by exactly one tool.
24
+ - A **Toolbox** is the sole catalog and mutation authority for one local state
25
+ directory.
26
+ - A **loadout** is a set of tool or action ids used to derive a read-only view
27
+ of the Toolbox.
28
+ - A **driver** executes actions for one tool type.
29
+ - A **system tool** is an embedder-owned definition projected into the catalog;
30
+ it is not user state.
31
+
32
+ ## Axioms
33
+
34
+ 1. Every action belongs to exactly one tool and has the canonical id
35
+ `<tool-id>.<action-name>`.
36
+ 2. Tool ids are stable, globally unique within one Toolbox, and never change
37
+ as a side effect of renaming display text.
38
+ 3. A tool's portable definition is its artifact file: one file per tool,
39
+ the tool and all of its actions together, never a second tool in the
40
+ same file. The Toolbox database is a derived index maintained for
41
+ runtime and realtime sync; SDK, CLI, and MCP adapters never keep
42
+ parallel registries.
43
+ 4. Applying a tool definition is atomic and authoritative for that tool: its
44
+ supplied action set replaces the previous set completely.
45
+ 5. Deleting a tool deletes all of its actions in the same transaction; an
46
+ orphan action can never be observed.
47
+ 6. Disabled tools expose and execute no actions. Disabled actions are likewise
48
+ absent from every runnable surface.
49
+ 7. A missing loadout means all enabled actions; an explicit empty loadout
50
+ means none. Selection never grants a capability not present in the catalog.
51
+ 8. Tool and action selection use ids only. Display names, ordering, and MCP
52
+ names never confer authority.
53
+ 9. MCP action names are a deterministic, collision-checked projection of
54
+ canonical action ids.
55
+ 10. Drivers declare which normalized tools they can execute, receive validated
56
+ object input, and never read or mutate Toolbox state.
57
+ 11. CLI execution never invokes a shell. API execution never changes origin
58
+ from the registered base URL, including through redirects.
59
+ 12. Credentials are supplied at execution time by the embedder. Secret values
60
+ are never stored in a tool or action record.
61
+ 13. Output is bounded before it enters an agent context, and cancellation or a
62
+ timeout terminates the underlying execution.
63
+ 14. MCP tools are connection definitions. Their actions are advertised only
64
+ when the composing MCP host supplies a driver that supports that exact
65
+ connection; Toolbox does not invent a second MCP session owner.
66
+ 15. System tools are immutable projections supplied by the embedder. Removing
67
+ an embedder projection removes it from the catalog without mutating user
68
+ state.
69
+ 16. The SDK owns behavior. CLI, MCP, HTTP, UI, and Engine integrations only
70
+ adapt the public Toolbox service.
71
+ 17. TypeScript under `src/` is the product source of truth. JavaScript and
72
+ declarations under `dist/` are generated together and are never edited by
73
+ hand.
74
+ 18. Tools may store opaque references owned by another product, but neither
75
+ Tools nor a Core adapter silently rewrites that product. A cross-product
76
+ workflow has one explicit product owner.
77
+
78
+ ## Predictable behavior
79
+
80
+ Applying the same definition twice produces the same catalog. Applying a
81
+ changed action set replaces the old set atomically. Selecting a whole tool
82
+ grants all of its enabled actions; selecting one action grants only that
83
+ action. Disabling or deleting a tool immediately removes all of its actions
84
+ from list and call surfaces. CLI arguments are passed directly to a process,
85
+ API paths stay under their registered origin, and MCP calls cross only the
86
+ driver boundary supplied by the composing host. Engine and standalone users
87
+ therefore get the same identities, validation, selection, and execution rules.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Amalgm Tools
2
+
3
+ `@amalgm/tools` is the standalone Tools product from Amalgm Engine. It owns
4
+ tool definitions, the local Toolbox catalog, loadout selection, execution,
5
+ and MCP projection. It can be used without the rest of Amalgm.
6
+
7
+ ```js
8
+ import { defineTool, Toolbox } from '@amalgm/tools';
9
+
10
+ const toolbox = new Toolbox({ stateDir: './.tools' });
11
+ await toolbox.apply(defineTool({
12
+ id: 'hello',
13
+ name: 'Hello',
14
+ source: { type: 'cli', command: 'node', inputMode: 'json-stdin', outputMode: 'json' },
15
+ actions: [{
16
+ name: 'say',
17
+ target: { args: ['./hello.mjs'] },
18
+ inputSchema: {
19
+ type: 'object',
20
+ required: ['name'],
21
+ properties: { name: { type: 'string' } },
22
+ additionalProperties: false,
23
+ },
24
+ }],
25
+ }));
26
+
27
+ const result = await toolbox.call('hello.say', { name: 'Ada' });
28
+ toolbox.close();
29
+ ```
30
+
31
+ ## State directory
32
+
33
+ Given no options, the Toolbox resolves its state directory through the
34
+ one product state-dir law in `@amalgm/core`: `AMALGM_TOOLS_DIR` is used
35
+ verbatim when set, then `AMALGM_DIR/toolbox`, then the scoped layout
36
+ `~/.amalgm/users/<scope>/toolbox`. Passing `stateDir` or `databaseFile`
37
+ bypasses resolution entirely.
38
+
39
+ ## CLI
40
+
41
+ ```sh
42
+ amalgm-tools apply ./tool.mjs
43
+ amalgm-tools list
44
+ amalgm-tools run hello.say --input '{"name":"Ada"}'
45
+ amalgm-tools disable hello.say
46
+ amalgm-tools connections
47
+ ```
48
+
49
+ `amalgm-tools-mcp` serves Toolbox management and enabled action tools over
50
+ newline-delimited MCP stdio.
51
+
52
+ ## Tool types
53
+
54
+ - `cli`: executed directly with `spawn`; a shell is never involved.
55
+ - `api`: same-origin HTTP actions with redirects refused.
56
+ - `mcp`: a connection plus action projection. The composing MCP host supplies
57
+ the one MCP driver that owns discovery, sessions, and calls.
58
+
59
+ Secret values are never part of definitions. `secretEnv` and `secretHeaders`
60
+ contain opaque references resolved through `CallOptions.resolveSecret`.
61
+ See [PURPOSE.md](./PURPOSE.md) for the complete contract and
62
+ [ENGINE_INTEGRATION.md](./docs/ENGINE_INTEGRATION.md) for composition.
@@ -0,0 +1,3 @@
1
+ import type { ToolDriver } from './types.js';
2
+ declare const apiDriver: ToolDriver;
3
+ export { apiDriver };
@@ -0,0 +1,92 @@
1
+ import { boundedResult, errorResult, structuredResult, textResult } from './results.js';
2
+ import { resolveReferences } from './secrets.js';
3
+ function fillPath(path, input) {
4
+ const params = (input.pathParams || input);
5
+ return path.replace(/\{([^}]+)\}/g, (_, key) => {
6
+ if (params[key] === undefined)
7
+ throw new Error(`Missing API path parameter: ${key}`);
8
+ return encodeURIComponent(String(params[key]));
9
+ });
10
+ }
11
+ async function readBody(response, maximum) {
12
+ if (!response.body)
13
+ return { text: '', truncated: false };
14
+ const reader = response.body.getReader();
15
+ const chunks = [];
16
+ let size = 0;
17
+ let truncated = false;
18
+ while (true) {
19
+ const { done, value } = await reader.read();
20
+ if (done)
21
+ break;
22
+ const room = Math.max(0, maximum - size);
23
+ if (value.length > room)
24
+ truncated = true;
25
+ if (room > 0) {
26
+ chunks.push(value.subarray(0, room));
27
+ size += Math.min(room, value.length);
28
+ }
29
+ }
30
+ return { text: Buffer.concat(chunks).toString('utf8'), truncated };
31
+ }
32
+ function timeoutSignal(timeoutMs, parent) {
33
+ const controller = new AbortController();
34
+ const abort = () => controller.abort(parent?.reason);
35
+ parent?.addEventListener('abort', abort, { once: true });
36
+ const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
37
+ timer.unref();
38
+ return {
39
+ signal: controller.signal,
40
+ close() { clearTimeout(timer); parent?.removeEventListener('abort', abort); },
41
+ };
42
+ }
43
+ const apiDriver = {
44
+ type: 'api',
45
+ async call({ tool, action, input, options }) {
46
+ const source = tool.source;
47
+ const target = action.target;
48
+ const base = new URL(source.baseUrl);
49
+ const url = new URL(fillPath(target.path, input), base);
50
+ if (url.origin !== base.origin)
51
+ throw new Error(`API action ${action.id} leaves its registered origin`);
52
+ const query = input.query;
53
+ if (query && (typeof query !== 'object' || Array.isArray(query)))
54
+ throw new Error('input.query must be an object');
55
+ for (const [key, value] of Object.entries((query || {}))) {
56
+ if (value !== undefined && value !== null)
57
+ url.searchParams.append(key, String(value));
58
+ }
59
+ const headers = await resolveReferences(source.secretHeaders, options.resolveSecret);
60
+ const method = target.method.toUpperCase();
61
+ const timed = timeoutSignal(source.timeoutMs, options.signal);
62
+ try {
63
+ const response = await fetch(url, {
64
+ method, redirect: 'manual', signal: timed.signal,
65
+ headers: { 'content-type': 'application/json', ...headers },
66
+ body: ['GET', 'HEAD'].includes(method) ? undefined : JSON.stringify(input.body ?? input),
67
+ });
68
+ if (response.status >= 300 && response.status < 400)
69
+ return errorResult(`API tool ${action.id} refused redirect`);
70
+ const body = await readBody(response, source.maxOutputBytes);
71
+ if (!response.ok)
72
+ return errorResult(`HTTP ${response.status} ${response.statusText}\n\n${body.text}`);
73
+ let result;
74
+ try {
75
+ result = structuredResult(JSON.parse(body.text));
76
+ }
77
+ catch {
78
+ result = textResult(body.text);
79
+ }
80
+ return { ...boundedResult(result, source.maxOutputBytes), ...(body.truncated ? { truncated: true } : {}) };
81
+ }
82
+ catch (error) {
83
+ if (timed.signal.aborted)
84
+ return errorResult(options.signal?.aborted ? `Tool ${action.id} was cancelled` : `Tool ${action.id} timed out`);
85
+ throw error;
86
+ }
87
+ finally {
88
+ timed.close();
89
+ }
90
+ },
91
+ };
92
+ export { apiDriver };
@@ -0,0 +1,28 @@
1
+ import type { ActionRecord, Catalog, ToolRecord } from './types.js';
2
+ declare class ArtifactFiles {
3
+ private readonly directory;
4
+ constructor(directory: string);
5
+ private file;
6
+ writeTool(entry: {
7
+ tool: ToolRecord;
8
+ actions: ActionRecord[];
9
+ }): string;
10
+ removeTool(toolId: string): void;
11
+ writeIndex(catalog: Catalog): void;
12
+ /** Give every persisted user tool its portable artifact and refresh the
13
+ * index. Additive only: a file for a tool this catalog does not know is
14
+ * never deleted here — removal is an explicit mutation. */
15
+ materialize(catalog: Catalog): void;
16
+ /** Execute the legacy `toolbox.json` migration plan: per-tool artifacts
17
+ * first, then the index, and only then the rename that retires the
18
+ * aggregate file. */
19
+ migrateLegacy(): boolean;
20
+ /** Read every valid per-tool artifact back. A document only counts when
21
+ * its file carries the exact name its tool id demands — a copied or
22
+ * renamed file never smuggles in a second identity. */
23
+ read(): {
24
+ tools: Record<string, ToolRecord>;
25
+ toolActions: Record<string, ActionRecord>;
26
+ };
27
+ }
28
+ export { ArtifactFiles };
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { LEGACY_TOOLBOX_FILE_NAME, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, userArtifactFileName, } from './artifacts.js';
4
+ /**
5
+ * The host adapter for tool artifacts: it executes the pure documents and
6
+ * plans from `artifacts.ts` against one state directory. Writes are atomic
7
+ * (tmp file + rename) and skipped when the bytes are unchanged, matching
8
+ * Engine `lib/storage.js`.
9
+ *
10
+ * DELIBERATE DEVIATION: Engine keeps the index and the legacy backup
11
+ * beside — not inside — its cloud-backed toolbox folder. A standalone
12
+ * state directory already holds the local `tools.db` cache, so both files
13
+ * live inside it, next to the database.
14
+ */
15
+ function readJson(file) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function writeJsonIfChanged(file, value) {
24
+ if (JSON.stringify(readJson(file)) === JSON.stringify(value))
25
+ return;
26
+ fs.mkdirSync(path.dirname(file), { recursive: true });
27
+ const temporary = `${file}.tmp`;
28
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2));
29
+ fs.renameSync(temporary, file);
30
+ }
31
+ class ArtifactFiles {
32
+ directory;
33
+ constructor(directory) {
34
+ this.directory = directory;
35
+ }
36
+ file(name) { return path.join(this.directory, name); }
37
+ writeTool(entry) {
38
+ const target = this.file(userArtifactFileName(entry.tool.id));
39
+ writeJsonIfChanged(target, artifactDocument(entry));
40
+ return target;
41
+ }
42
+ removeTool(toolId) {
43
+ try {
44
+ fs.unlinkSync(this.file(userArtifactFileName(toolId)));
45
+ }
46
+ catch (error) {
47
+ if (error.code !== 'ENOENT')
48
+ throw error;
49
+ }
50
+ }
51
+ writeIndex(catalog) {
52
+ writeJsonIfChanged(this.file(TOOLBOX_INDEX_FILE_NAME), catalogIndexDocument(catalog));
53
+ }
54
+ /** Give every persisted user tool its portable artifact and refresh the
55
+ * index. Additive only: a file for a tool this catalog does not know is
56
+ * never deleted here — removal is an explicit mutation. */
57
+ materialize(catalog) {
58
+ for (const tool of catalog.tools) {
59
+ if (tool.origin === 'system')
60
+ continue;
61
+ this.writeTool({ tool, actions: catalog.actions.filter((action) => action.toolId === tool.id) });
62
+ }
63
+ this.writeIndex(catalog);
64
+ }
65
+ /** Execute the legacy `toolbox.json` migration plan: per-tool artifacts
66
+ * first, then the index, and only then the rename that retires the
67
+ * aggregate file. */
68
+ migrateLegacy() {
69
+ const legacy = this.file(LEGACY_TOOLBOX_FILE_NAME);
70
+ if (!fs.existsSync(legacy))
71
+ return false;
72
+ const plan = legacyMigrationPlan(readJson(legacy));
73
+ if (!plan.migrated || !plan.rename)
74
+ return false;
75
+ for (const write of plan.files)
76
+ writeJsonIfChanged(this.file(write.fileName), write.document);
77
+ if (plan.index)
78
+ writeJsonIfChanged(this.file(TOOLBOX_INDEX_FILE_NAME), plan.index);
79
+ let target = this.file(plan.rename.to);
80
+ if (fs.existsSync(target))
81
+ target = this.file(`toolbox.legacy-${Date.now()}.json`);
82
+ fs.renameSync(legacy, target);
83
+ return true;
84
+ }
85
+ /** Read every valid per-tool artifact back. A document only counts when
86
+ * its file carries the exact name its tool id demands — a copied or
87
+ * renamed file never smuggles in a second identity. */
88
+ read() {
89
+ const tools = {};
90
+ const toolActions = {};
91
+ let entries = [];
92
+ try {
93
+ entries = fs.readdirSync(this.directory, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ return { tools, toolActions };
97
+ }
98
+ for (const entry of entries) {
99
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name === LEGACY_TOOLBOX_FILE_NAME)
100
+ continue;
101
+ const document = readJson(path.join(this.directory, entry.name));
102
+ if (!isToolArtifactDocument(document))
103
+ continue;
104
+ const toolId = safeToolId(document.tool.id);
105
+ if (userArtifactFileName(toolId) !== entry.name)
106
+ continue;
107
+ tools[toolId] = { ...document.tool, id: toolId };
108
+ for (const action of document.actions) {
109
+ toolActions[String(action.id)] = { ...action, toolId };
110
+ }
111
+ }
112
+ return { tools, toolActions };
113
+ }
114
+ }
115
+ export { ArtifactFiles };
@@ -0,0 +1,66 @@
1
+ import type { ActionRecord, Catalog, ToolRecord } from './types.js';
2
+ /**
3
+ * Tool artifact format laws — the portable representation of a tool.
4
+ *
5
+ * One tool is one artifact file: the tool definition and every action it
6
+ * owns travel together, and a file never carries a second tool. Artifact
7
+ * files are the portable definition; the Toolbox database and
8
+ * `toolbox.index.json` are derived caches. The format is ported byte-exact
9
+ * from Engine `runtime/scripts/amalgm-mcp/toolbox/artifacts.js`.
10
+ *
11
+ * Pure laws only: nothing here touches the filesystem. `artifact-files.ts`
12
+ * is the host adapter that executes these documents and plans.
13
+ *
14
+ * DELIBERATE DEVIATION: Engine recognizes system tools by its managed
15
+ * artifact list; this product's system tools are embedder projections
16
+ * (Axiom 15) and are recognized by `origin: 'system'`. The portable-source
17
+ * law is unchanged: a system tool's `127.0.0.1:<port>` binding is a device
18
+ * fact, never tool identity, so its artifact carries
19
+ * `{ transport: 'amalgm-runtime', service: 'mcp' }` instead.
20
+ */
21
+ declare const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
22
+ declare const TOOL_ARTIFACT_KIND = "amalgm-tool";
23
+ declare const LEGACY_TOOLBOX_FILE_NAME = "toolbox.json";
24
+ declare const LEGACY_TOOLBOX_BACKUP_FILE_NAME = "toolbox.legacy.json";
25
+ declare const TOOLBOX_INDEX_FILE_NAME = "toolbox.index.json";
26
+ type ArtifactRecord = Record<string, unknown>;
27
+ interface ToolArtifactDocument {
28
+ schemaVersion: typeof TOOL_ARTIFACT_SCHEMA_VERSION;
29
+ kind: typeof TOOL_ARTIFACT_KIND;
30
+ tool: ArtifactRecord;
31
+ actions: ArtifactRecord[];
32
+ }
33
+ interface ToolboxIndexDocument {
34
+ version: 1;
35
+ tools: Record<string, unknown>;
36
+ toolActions: Record<string, unknown>;
37
+ }
38
+ interface ArtifactFileWrite {
39
+ fileName: string;
40
+ document: ToolArtifactDocument;
41
+ }
42
+ interface LegacyMigrationPlan {
43
+ migrated: boolean;
44
+ files: ArtifactFileWrite[];
45
+ index: ToolboxIndexDocument | null;
46
+ rename: {
47
+ from: string;
48
+ to: string;
49
+ } | null;
50
+ }
51
+ declare function safeToolId(value: unknown): string;
52
+ declare function userArtifactFileName(toolId: unknown): string;
53
+ declare function artifactDocument(input: {
54
+ tool: ToolRecord;
55
+ actions?: readonly ActionRecord[];
56
+ }): ToolArtifactDocument;
57
+ declare function isToolArtifactDocument(value: unknown): value is ToolArtifactDocument;
58
+ declare function toolboxIndexDocument(tools: Record<string, unknown>, toolActions: Record<string, unknown>): ToolboxIndexDocument;
59
+ declare function catalogIndexDocument(catalog: Pick<Catalog, 'tools' | 'actions'>): ToolboxIndexDocument;
60
+ /** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
61
+ * plan: every user tool becomes a per-tool artifact write, the whole legacy
62
+ * catalog becomes the index, and the aggregate file is renamed out of the
63
+ * way only after each record has a per-tool replacement. */
64
+ declare function legacyMigrationPlan(legacy: unknown): LegacyMigrationPlan;
65
+ export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
66
+ export type { ArtifactFileWrite, LegacyMigrationPlan, ToolArtifactDocument, ToolboxIndexDocument };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tool artifact format laws — the portable representation of a tool.
3
+ *
4
+ * One tool is one artifact file: the tool definition and every action it
5
+ * owns travel together, and a file never carries a second tool. Artifact
6
+ * files are the portable definition; the Toolbox database and
7
+ * `toolbox.index.json` are derived caches. The format is ported byte-exact
8
+ * from Engine `runtime/scripts/amalgm-mcp/toolbox/artifacts.js`.
9
+ *
10
+ * Pure laws only: nothing here touches the filesystem. `artifact-files.ts`
11
+ * is the host adapter that executes these documents and plans.
12
+ *
13
+ * DELIBERATE DEVIATION: Engine recognizes system tools by its managed
14
+ * artifact list; this product's system tools are embedder projections
15
+ * (Axiom 15) and are recognized by `origin: 'system'`. The portable-source
16
+ * law is unchanged: a system tool's `127.0.0.1:<port>` binding is a device
17
+ * fact, never tool identity, so its artifact carries
18
+ * `{ transport: 'amalgm-runtime', service: 'mcp' }` instead.
19
+ */
20
+ const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
21
+ const TOOL_ARTIFACT_KIND = 'amalgm-tool';
22
+ const LEGACY_TOOLBOX_FILE_NAME = 'toolbox.json';
23
+ const LEGACY_TOOLBOX_BACKUP_FILE_NAME = 'toolbox.legacy.json';
24
+ const TOOLBOX_INDEX_FILE_NAME = 'toolbox.index.json';
25
+ function safeToolId(value) {
26
+ return String(value || '')
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9_.-]+/g, '.')
30
+ .replace(/\.+/g, '.')
31
+ .replace(/^[._-]+|[._-]+$/g, '');
32
+ }
33
+ function userArtifactFileName(toolId) {
34
+ const id = safeToolId(toolId);
35
+ if (!id)
36
+ throw new Error('tool artifact requires a stable tool id');
37
+ return `tool-${id}.json`;
38
+ }
39
+ function artifactDocument(input) {
40
+ const { tool, actions = [] } = input;
41
+ if (!tool || typeof tool !== 'object' || !safeToolId(tool.id)) {
42
+ throw new Error('tool artifact requires a tool with an id');
43
+ }
44
+ const toolId = safeToolId(tool.id);
45
+ const normalizedActions = Array.isArray(actions)
46
+ ? actions.filter((action) => action && action.toolId === toolId)
47
+ : [];
48
+ const portableTool = tool.origin === 'system'
49
+ ? {
50
+ ...tool,
51
+ id: toolId,
52
+ // `127.0.0.1:<port>` is a device binding, not tool identity. The
53
+ // embedder resolves the current runtime endpoint when serving the
54
+ // local tool surface.
55
+ source: { transport: 'amalgm-runtime', service: 'mcp' },
56
+ }
57
+ : { ...tool, id: toolId };
58
+ return {
59
+ schemaVersion: TOOL_ARTIFACT_SCHEMA_VERSION,
60
+ kind: TOOL_ARTIFACT_KIND,
61
+ tool: portableTool,
62
+ actions: normalizedActions
63
+ .map((action) => ({ ...action, toolId }))
64
+ .sort((left, right) => String(left.id).localeCompare(String(right.id))),
65
+ };
66
+ }
67
+ function isToolArtifactDocument(value) {
68
+ const document = value;
69
+ return !!document
70
+ && document.schemaVersion === TOOL_ARTIFACT_SCHEMA_VERSION
71
+ && document.kind === TOOL_ARTIFACT_KIND
72
+ && !!document.tool
73
+ && typeof document.tool === 'object'
74
+ && !!safeToolId(document.tool.id)
75
+ && Array.isArray(document.actions)
76
+ && document.actions.every((action) => action && action.toolId === safeToolId(document.tool.id));
77
+ }
78
+ function toolboxIndexDocument(tools, toolActions) {
79
+ return { version: 1, tools: tools || {}, toolActions: toolActions || {} };
80
+ }
81
+ function catalogIndexDocument(catalog) {
82
+ return toolboxIndexDocument(Object.fromEntries(catalog.tools.map((tool) => [tool.id, tool])), Object.fromEntries(catalog.actions.map((action) => [action.id, action])));
83
+ }
84
+ /** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
85
+ * plan: every user tool becomes a per-tool artifact write, the whole legacy
86
+ * catalog becomes the index, and the aggregate file is renamed out of the
87
+ * way only after each record has a per-tool replacement. */
88
+ function legacyMigrationPlan(legacy) {
89
+ const parsed = legacy;
90
+ if (!parsed || typeof parsed !== 'object' || !parsed.tools || typeof parsed.tools !== 'object') {
91
+ return { migrated: false, files: [], index: null, rename: null };
92
+ }
93
+ const files = [];
94
+ for (const [rawId, rawTool] of Object.entries(parsed.tools)) {
95
+ const toolId = safeToolId(rawTool?.id || rawId);
96
+ if (!toolId || !rawTool || typeof rawTool !== 'object' || rawTool.origin === 'system')
97
+ continue;
98
+ const actions = Object.values(parsed.toolActions || {})
99
+ .filter((action) => action && action.toolId === toolId);
100
+ files.push({
101
+ fileName: userArtifactFileName(toolId),
102
+ document: artifactDocument({ tool: { ...rawTool, id: toolId }, actions }),
103
+ });
104
+ }
105
+ return {
106
+ migrated: true,
107
+ files,
108
+ index: toolboxIndexDocument(parsed.tools || {}, parsed.toolActions || {}),
109
+ rename: { from: LEGACY_TOOLBOX_FILE_NAME, to: LEGACY_TOOLBOX_BACKUP_FILE_NAME },
110
+ };
111
+ }
112
+ export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { createMcpServer } from '../mcp-server.js';
3
+ const args = process.argv.slice(2);
4
+ const stateIndex = args.indexOf('--state-dir');
5
+ const stateDir = stateIndex >= 0 ? args[stateIndex + 1] : undefined;
6
+ const server = createMcpServer({ stateDir });
7
+ server.start();
8
+ const close = () => {
9
+ server.toolbox.close();
10
+ process.exit(0);
11
+ };
12
+ process.once('SIGINT', close);
13
+ process.once('SIGTERM', close);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../cli.js';
3
+ void runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
@@ -0,0 +1,3 @@
1
+ import type { ToolDriver } from './types.js';
2
+ declare const cliDriver: ToolDriver;
3
+ export { cliDriver };
@@ -0,0 +1,52 @@
1
+ import { runCommand } from './process.js';
2
+ import { errorResult, structuredResult, textResult } from './results.js';
3
+ import { resolveReferences } from './secrets.js';
4
+ function inputArguments(input) {
5
+ const args = input.args;
6
+ if (!Array.isArray(args) || args.some((value) => typeof value !== 'string')) {
7
+ throw new Error('CLI argv input requires a string array at input.args');
8
+ }
9
+ return args;
10
+ }
11
+ const cliDriver = {
12
+ type: 'cli',
13
+ async call({ tool, action, input, options }) {
14
+ const source = tool.source;
15
+ const target = action.target;
16
+ const environment = await resolveReferences(source.secretEnv, options.resolveSecret);
17
+ const args = [...(source.args || []), ...(target.args || [])];
18
+ if (source.inputMode === 'argv')
19
+ args.push(...inputArguments(input));
20
+ const output = await runCommand({
21
+ command: source.command, args, cwd: source.cwd,
22
+ env: { ...process.env, ...environment },
23
+ stdin: source.inputMode === 'json-stdin' ? `${JSON.stringify(input)}\n` : undefined,
24
+ timeoutMs: source.timeoutMs, maximumBytes: source.maxOutputBytes, signal: options.signal,
25
+ });
26
+ const stdout = output.stdout.trim();
27
+ const stderr = output.stderr.trim();
28
+ if (output.timedOut)
29
+ return errorResult(`Tool ${action.id} timed out after ${source.timeoutMs}ms`);
30
+ if (output.aborted)
31
+ return errorResult(`Tool ${action.id} was cancelled`);
32
+ if (output.code !== 0) {
33
+ return errorResult([
34
+ `Tool ${action.id} exited with ${output.code ?? output.signal ?? 'unknown'}.`,
35
+ stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`,
36
+ ].filter(Boolean).join('\n\n'));
37
+ }
38
+ let result;
39
+ if (source.outputMode === 'json' && stdout) {
40
+ try {
41
+ result = structuredResult(JSON.parse(stdout));
42
+ }
43
+ catch {
44
+ return errorResult(`Tool ${action.id} returned invalid JSON`);
45
+ }
46
+ }
47
+ else
48
+ result = textResult(stdout || `Tool ${action.id} completed.`);
49
+ return { ...result, ...(output.truncated ? { truncated: true } : {}) };
50
+ },
51
+ };
52
+ export { cliDriver };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { CliIo, LoadoutInput } from './types.js';
2
+ declare const HELP = "Usage: amalgm-tools [--state-dir DIR] [--loadout IDS] <command>\n\nCommands:\n apply <definition.js|json> Apply one tool or an array of tools\n list List tools and enabled actions\n catalog Print the complete canonical catalog\n show <tool-or-action-id> Show one record\n enable <tool-or-action-id> Enable one record\n disable <tool-or-action-id> Disable one record\n remove <tool-or-action-id> Remove one record\n run <action-id> [--input JSON]\n Run an action\n connections List MCP connections for a composing host";
3
+ interface Parsed {
4
+ command?: string;
5
+ args: string[];
6
+ stateDir?: string;
7
+ loadout?: LoadoutInput;
8
+ input: Record<string, unknown>;
9
+ }
10
+ declare function parse(argv: string[]): Parsed;
11
+ declare function runCli(argv: string[], io?: CliIo): Promise<number>;
12
+ export { HELP, parse, runCli };
13
+ export type { Parsed };