@enactprotocol/cli 2.1.5 → 2.1.6

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * enact learn command
3
+ *
4
+ * Display the documentation (enact.md) for a tool.
5
+ * Fetches and displays the raw manifest content for easy reading.
6
+ */
7
+
8
+ import { createApiClient, getToolInfo, getToolVersion } from "@enactprotocol/api";
9
+ import { loadConfig } from "@enactprotocol/shared";
10
+ import type { Command } from "commander";
11
+ import type { CommandContext, GlobalOptions } from "../../types";
12
+ import { dim, error, formatError, header, json, newline } from "../../utils";
13
+
14
+ interface LearnOptions extends GlobalOptions {
15
+ ver?: string;
16
+ }
17
+
18
+ /**
19
+ * Learn command handler
20
+ */
21
+ async function learnHandler(
22
+ toolName: string,
23
+ options: LearnOptions,
24
+ _ctx: CommandContext
25
+ ): Promise<void> {
26
+ const config = loadConfig();
27
+ const registryUrl =
28
+ process.env.ENACT_REGISTRY_URL ??
29
+ config.registry?.url ??
30
+ "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
31
+ const authToken = config.registry?.authToken;
32
+ const client = createApiClient({
33
+ baseUrl: registryUrl,
34
+ authToken: authToken,
35
+ });
36
+
37
+ try {
38
+ // Get the version to fetch - either specified or latest
39
+ let version = options.ver;
40
+ if (!version) {
41
+ const toolInfo = await getToolInfo(client, toolName);
42
+ version = toolInfo.latestVersion;
43
+ }
44
+
45
+ // Get the version info which includes rawManifest
46
+ const versionInfo = await getToolVersion(client, toolName, version);
47
+
48
+ if (options.json) {
49
+ json({
50
+ name: toolName,
51
+ version: versionInfo.version,
52
+ documentation: versionInfo.rawManifest ?? null,
53
+ });
54
+ return;
55
+ }
56
+
57
+ if (!versionInfo.rawManifest) {
58
+ error(`No documentation found for ${toolName}@${version}`);
59
+ dim("This tool may not have an enact.md file.");
60
+ process.exit(1);
61
+ }
62
+
63
+ // Display the documentation
64
+ header(`${toolName}@${version}`);
65
+ newline();
66
+ console.log(versionInfo.rawManifest);
67
+ } catch (err) {
68
+ if (err instanceof Error) {
69
+ if (err.message.includes("not_found") || err.message.includes("404")) {
70
+ error(`Tool not found: ${toolName}`);
71
+ dim("Check the tool name or search with: enact search <query>");
72
+ process.exit(1);
73
+ }
74
+ if (err.message.includes("fetch")) {
75
+ error("Unable to connect to registry. Check your internet connection.");
76
+ process.exit(1);
77
+ }
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Configure the learn command
85
+ */
86
+ export function configureLearnCommand(program: Command): void {
87
+ program
88
+ .command("learn <tool>")
89
+ .description("Display documentation (enact.md) for a tool")
90
+ .option("--ver <version>", "Show documentation for a specific version")
91
+ .option("--json", "Output as JSON")
92
+ .action(async (toolName: string, options: LearnOptions) => {
93
+ const ctx: CommandContext = {
94
+ cwd: process.cwd(),
95
+ options,
96
+ isCI: Boolean(process.env.CI),
97
+ isInteractive: process.stdout.isTTY ?? false,
98
+ };
99
+
100
+ try {
101
+ await learnHandler(toolName, options, ctx);
102
+ } catch (err) {
103
+ error(formatError(err));
104
+ process.exit(1);
105
+ }
106
+ });
107
+ }
@@ -61,6 +61,7 @@ import {
61
61
 
62
62
  interface RunOptions extends GlobalOptions {
63
63
  args?: string;
64
+ inputFile?: string;
64
65
  input?: string[];
65
66
  timeout?: string;
66
67
  noCache?: boolean;
@@ -70,14 +71,46 @@ interface RunOptions extends GlobalOptions {
70
71
 
71
72
  /**
72
73
  * Parse input arguments from various formats
74
+ *
75
+ * Priority order (later sources override earlier):
76
+ * 1. --input-file (JSON file)
77
+ * 2. --args (inline JSON)
78
+ * 3. --input (key=value pairs)
79
+ *
80
+ * Recommended for agents: Use --args or --input-file with JSON
73
81
  */
74
82
  function parseInputArgs(
75
83
  argsJson: string | undefined,
84
+ inputFile: string | undefined,
76
85
  inputFlags: string[] | undefined
77
86
  ): Record<string, unknown> {
78
87
  const inputs: Record<string, unknown> = {};
79
88
 
80
- // Parse --args JSON
89
+ // Parse --input-file JSON file (loaded first, can be overridden)
90
+ if (inputFile) {
91
+ try {
92
+ const { readFileSync, existsSync } = require("node:fs");
93
+ const { resolve } = require("node:path");
94
+ const filePath = resolve(inputFile);
95
+
96
+ if (!existsSync(filePath)) {
97
+ throw new Error(`Input file not found: ${inputFile}`);
98
+ }
99
+
100
+ const content = readFileSync(filePath, "utf-8");
101
+ const parsed = JSON.parse(content);
102
+ if (typeof parsed === "object" && parsed !== null) {
103
+ Object.assign(inputs, parsed);
104
+ }
105
+ } catch (err) {
106
+ if (err instanceof Error && err.message.startsWith("Input file not found")) {
107
+ throw err;
108
+ }
109
+ throw new Error(`Invalid JSON in input file: ${formatError(err)}`);
110
+ }
111
+ }
112
+
113
+ // Parse --args JSON (overrides file)
81
114
  if (argsJson) {
82
115
  try {
83
116
  const parsed = JSON.parse(argsJson);
@@ -89,7 +122,7 @@ function parseInputArgs(
89
122
  }
90
123
  }
91
124
 
92
- // Parse --input key=value pairs
125
+ // Parse --input key=value pairs (overrides both)
93
126
  if (inputFlags) {
94
127
  for (const input of inputFlags) {
95
128
  const eqIndex = input.indexOf("=");
@@ -501,7 +534,7 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
501
534
  const manifest = resolution.manifest;
502
535
 
503
536
  // Parse inputs
504
- const inputs = parseInputArgs(options.args, options.input);
537
+ const inputs = parseInputArgs(options.args, options.inputFile, options.input);
505
538
 
506
539
  // Apply defaults from schema
507
540
  const inputsWithDefaults = manifest.inputSchema
@@ -669,8 +702,9 @@ export function configureRunCommand(program: Command): void {
669
702
  .command("run")
670
703
  .description("Execute a tool with its manifest-defined command")
671
704
  .argument("<tool>", "Tool to run (name, path, or '.' for current directory)")
672
- .option("-a, --args <json>", "Input arguments as JSON")
673
- .option("-i, --input <key=value...>", "Input arguments as key=value pairs")
705
+ .option("-a, --args <json>", "Input arguments as JSON string (recommended)")
706
+ .option("-f, --input-file <path>", "Load input arguments from JSON file")
707
+ .option("-i, --input <key=value...>", "Input arguments as key=value pairs (simple values only)")
674
708
  .option("-t, --timeout <duration>", "Execution timeout (e.g., 30s, 5m)")
675
709
  .option("--no-cache", "Disable container caching")
676
710
  .option("--local", "Only resolve from local sources")
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  configureInitCommand,
20
20
  configureInspectCommand,
21
21
  configureInstallCommand,
22
+ configureLearnCommand,
22
23
  configureListCommand,
23
24
  configurePublishCommand,
24
25
  configureReportCommand,
@@ -32,7 +33,7 @@ import {
32
33
  } from "./commands";
33
34
  import { error, formatError } from "./utils";
34
35
 
35
- export const version = "2.1.5";
36
+ export const version = "2.1.6";
36
37
 
37
38
  // Export types for external use
38
39
  export type { GlobalOptions, CommandContext } from "./types";
@@ -47,7 +48,7 @@ async function main() {
47
48
  program
48
49
  .name("enact")
49
50
  .description("Enact - Verified, portable protocol for AI-executable tools")
50
- .version(version);
51
+ .version(version, "-v, --version", "output the version number");
51
52
 
52
53
  // Configure all commands
53
54
  configureSetupCommand(program);
@@ -63,6 +64,7 @@ async function main() {
63
64
  // Registry commands (Phase 8)
64
65
  configureSearchCommand(program);
65
66
  configureGetCommand(program);
67
+ configureLearnCommand(program);
66
68
  configurePublishCommand(program);
67
69
  configureAuthCommand(program);
68
70
  configureCacheCommand(program);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Tests for the learn command
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { ToolVersionInfo } from "@enactprotocol/api";
7
+ import { Command } from "commander";
8
+ import { configureLearnCommand } from "../../src/commands/learn";
9
+
10
+ describe("learn command", () => {
11
+ describe("command configuration", () => {
12
+ test("configures learn command on program", () => {
13
+ const program = new Command();
14
+ configureLearnCommand(program);
15
+
16
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
17
+ expect(learnCmd).toBeDefined();
18
+ });
19
+
20
+ test("has correct description", () => {
21
+ const program = new Command();
22
+ configureLearnCommand(program);
23
+
24
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
25
+ expect(learnCmd?.description()).toBe("Display documentation (enact.md) for a tool");
26
+ });
27
+
28
+ test("accepts tool argument", () => {
29
+ const program = new Command();
30
+ configureLearnCommand(program);
31
+
32
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
33
+ const args = learnCmd?.registeredArguments ?? [];
34
+ expect(args.length).toBeGreaterThan(0);
35
+ expect(args[0]?.name()).toBe("tool");
36
+ });
37
+
38
+ test("has --ver option for specifying version", () => {
39
+ const program = new Command();
40
+ configureLearnCommand(program);
41
+
42
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
43
+ const opts = learnCmd?.options ?? [];
44
+ const verOpt = opts.find((o) => o.long === "--ver");
45
+ expect(verOpt).toBeDefined();
46
+ });
47
+
48
+ test("has --json option", () => {
49
+ const program = new Command();
50
+ configureLearnCommand(program);
51
+
52
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
53
+ const opts = learnCmd?.options ?? [];
54
+ const jsonOpt = opts.find((o) => o.long === "--json");
55
+ expect(jsonOpt).toBeDefined();
56
+ });
57
+
58
+ test("does not have --verbose option (always shows full docs)", () => {
59
+ const program = new Command();
60
+ configureLearnCommand(program);
61
+
62
+ const learnCmd = program.commands.find((cmd) => cmd.name() === "learn");
63
+ const opts = learnCmd?.options ?? [];
64
+ const verboseOpt = opts.find((o) => o.long === "--verbose");
65
+ expect(verboseOpt).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe("tool name parsing", () => {
70
+ test("parses simple tool name", () => {
71
+ const toolName = "my-tool";
72
+ expect(toolName).not.toContain("@");
73
+ expect(toolName).not.toContain("/");
74
+ });
75
+
76
+ test("parses namespaced tool name", () => {
77
+ const toolName = "enact/context7/docs";
78
+ const parts = toolName.split("/");
79
+ expect(parts[0]).toBe("enact");
80
+ expect(parts[1]).toBe("context7");
81
+ expect(parts[2]).toBe("docs");
82
+ });
83
+
84
+ test("parses scoped tool name", () => {
85
+ const toolName = "@org/my-tool";
86
+ expect(toolName.startsWith("@")).toBe(true);
87
+ const parts = toolName.slice(1).split("/");
88
+ expect(parts[0]).toBe("org");
89
+ expect(parts[1]).toBe("my-tool");
90
+ });
91
+ });
92
+
93
+ describe("documentation content", () => {
94
+ test("ToolVersionInfo type includes rawManifest field for enact.md content", () => {
95
+ const mockVersion: ToolVersionInfo = {
96
+ name: "test/tool",
97
+ version: "1.0.0",
98
+ description: "Test tool",
99
+ license: "MIT",
100
+ yanked: false,
101
+ manifest: { enact: "2.0.0" },
102
+ rawManifest: "---\nenact: 2.0.0\n---\n# Test Tool\n\nThis is a test tool.",
103
+ bundle: {
104
+ hash: "sha256:abc123",
105
+ size: 1024,
106
+ downloadUrl: "https://example.com/bundle.tar.gz",
107
+ },
108
+ attestations: [],
109
+ publishedBy: { username: "testuser" },
110
+ publishedAt: new Date(),
111
+ downloads: 100,
112
+ };
113
+
114
+ expect(mockVersion.rawManifest).toBeDefined();
115
+ expect(mockVersion.rawManifest).toContain("# Test Tool");
116
+ });
117
+
118
+ test("ToolVersionInfo allows undefined rawManifest", () => {
119
+ const mockVersion: ToolVersionInfo = {
120
+ name: "test/tool",
121
+ version: "1.0.0",
122
+ description: "Test tool",
123
+ license: "MIT",
124
+ yanked: false,
125
+ manifest: { enact: "2.0.0" },
126
+ // rawManifest is optional - not provided
127
+ bundle: {
128
+ hash: "sha256:abc123",
129
+ size: 1024,
130
+ downloadUrl: "https://example.com/bundle.tar.gz",
131
+ },
132
+ attestations: [],
133
+ publishedBy: { username: "testuser" },
134
+ publishedAt: new Date(),
135
+ downloads: 100,
136
+ };
137
+
138
+ expect(mockVersion.rawManifest).toBeUndefined();
139
+ });
140
+
141
+ test("enact.md content should contain frontmatter and markdown", () => {
142
+ const enactMdContent = `---
143
+ enact: 2.0.0
144
+ name: test/tool
145
+ version: 1.0.0
146
+ ---
147
+
148
+ # Test Tool
149
+
150
+ Documentation here.`;
151
+
152
+ // Verify frontmatter is present
153
+ expect(enactMdContent).toContain("---");
154
+ expect(enactMdContent).toContain("enact: 2.0.0");
155
+
156
+ // Verify markdown content
157
+ expect(enactMdContent).toContain("# Test Tool");
158
+ expect(enactMdContent).toContain("Documentation here.");
159
+ });
160
+
161
+ test("documentation includes parameter descriptions", () => {
162
+ const enactMdContent = `---
163
+ enact: 2.0.0
164
+ name: enact/context7/docs
165
+ version: 1.0.0
166
+ inputSchema:
167
+ type: object
168
+ properties:
169
+ library_name:
170
+ type: string
171
+ description: The name of the library to fetch documentation for
172
+ ---
173
+
174
+ # Context7 Documentation Fetcher
175
+
176
+ Fetches up-to-date documentation for any library.
177
+
178
+ ## Parameters
179
+
180
+ - **library_name** (required): The name of the library to fetch documentation for
181
+ `;
182
+
183
+ expect(enactMdContent).toContain("library_name");
184
+ expect(enactMdContent).toContain("Parameters");
185
+ expect(enactMdContent).toContain("required");
186
+ });
187
+
188
+ test("documentation includes usage examples", () => {
189
+ const enactMdContent = `---
190
+ enact: 2.0.0
191
+ name: test/tool
192
+ ---
193
+
194
+ # Test Tool
195
+
196
+ ## Usage
197
+
198
+ \`\`\`bash
199
+ enact run test/tool --input '{"query": "hello"}'
200
+ \`\`\`
201
+ `;
202
+
203
+ expect(enactMdContent).toContain("## Usage");
204
+ expect(enactMdContent).toContain("enact run");
205
+ });
206
+ });
207
+
208
+ describe("JSON output format", () => {
209
+ test("JSON output includes name, version, and documentation", () => {
210
+ const jsonOutput = {
211
+ name: "enact/context7/docs",
212
+ version: "1.0.1",
213
+ documentation: "---\nenact: 2.0.0\n---\n# Context7 Docs\n\nFetches documentation.",
214
+ };
215
+
216
+ expect(jsonOutput.name).toBe("enact/context7/docs");
217
+ expect(jsonOutput.version).toBe("1.0.1");
218
+ expect(jsonOutput.documentation).toContain("# Context7 Docs");
219
+ });
220
+
221
+ test("JSON output handles missing documentation", () => {
222
+ const jsonOutput = {
223
+ name: "test/tool",
224
+ version: "1.0.0",
225
+ documentation: null,
226
+ };
227
+
228
+ expect(jsonOutput.documentation).toBeNull();
229
+ });
230
+ });
231
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { existsSync, mkdirSync, rmSync } from "node:fs";
6
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { Command } from "commander";
9
9
  import { configureRunCommand } from "../../src/commands/run";
@@ -71,6 +71,16 @@ describe("run command", () => {
71
71
  expect(inputOpt).toBeDefined();
72
72
  });
73
73
 
74
+ test("has --input-file option for JSON file input", () => {
75
+ const program = new Command();
76
+ configureRunCommand(program);
77
+
78
+ const runCmd = program.commands.find((cmd) => cmd.name() === "run");
79
+ const opts = runCmd?.options ?? [];
80
+ const inputFileOpt = opts.find((o) => o.long === "--input-file");
81
+ expect(inputFileOpt).toBeDefined();
82
+ });
83
+
74
84
  test("has --timeout option", () => {
75
85
  const program = new Command();
76
86
  configureRunCommand(program);
@@ -228,4 +238,63 @@ describe("run command", () => {
228
238
  expect(parseTimeout("30")).toBe(30000);
229
239
  });
230
240
  });
241
+
242
+ describe("input file handling", () => {
243
+ test("JSON input file can be parsed", () => {
244
+ // Create a test JSON file
245
+ const inputFilePath = join(FIXTURES_DIR, "test-inputs.json");
246
+ const inputData = { name: "Alice", count: 5, nested: { key: "value" } };
247
+ writeFileSync(inputFilePath, JSON.stringify(inputData));
248
+
249
+ // Verify the file can be read and parsed
250
+ const content = require("node:fs").readFileSync(inputFilePath, "utf-8");
251
+ const parsed = JSON.parse(content);
252
+
253
+ expect(parsed.name).toBe("Alice");
254
+ expect(parsed.count).toBe(5);
255
+ expect(parsed.nested.key).toBe("value");
256
+ });
257
+
258
+ test("JSON input file with optional params can omit them", () => {
259
+ // This is the recommended pattern for optional params
260
+ const inputFilePath = join(FIXTURES_DIR, "optional-inputs.json");
261
+ // Only required param provided, optional params omitted
262
+ const inputData = { name: "Alice" };
263
+ writeFileSync(inputFilePath, JSON.stringify(inputData));
264
+
265
+ const content = require("node:fs").readFileSync(inputFilePath, "utf-8");
266
+ const parsed = JSON.parse(content);
267
+
268
+ expect(parsed.name).toBe("Alice");
269
+ expect(parsed.greeting).toBeUndefined();
270
+ });
271
+
272
+ test("JSON input file with explicit empty values", () => {
273
+ // User can explicitly set empty values for optional params
274
+ const inputFilePath = join(FIXTURES_DIR, "explicit-empty.json");
275
+ const inputData = { name: "Alice", prefix: "", suffix: null };
276
+ writeFileSync(inputFilePath, JSON.stringify(inputData));
277
+
278
+ const content = require("node:fs").readFileSync(inputFilePath, "utf-8");
279
+ const parsed = JSON.parse(content);
280
+
281
+ expect(parsed.name).toBe("Alice");
282
+ expect(parsed.prefix).toBe("");
283
+ expect(parsed.suffix).toBeNull();
284
+ });
285
+
286
+ test("input priority: --input overrides --args overrides --input-file", () => {
287
+ // Simulate the merge logic from parseInputArgs
288
+ const fromFile = { a: "file", b: "file", c: "file" };
289
+ const fromArgs = { b: "args", c: "args" };
290
+ const fromInput = { c: "input" };
291
+
292
+ // Merge in order: file -> args -> input
293
+ const merged = { ...fromFile, ...fromArgs, ...fromInput };
294
+
295
+ expect(merged.a).toBe("file"); // Only from file
296
+ expect(merged.b).toBe("args"); // Overridden by args
297
+ expect(merged.c).toBe("input"); // Overridden by input
298
+ });
299
+ });
231
300
  });