@actiondock/cli 2.0.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.
@@ -0,0 +1,199 @@
1
+ import { resolve } from "node:path";
2
+ import { findProjectRoot, loadProjectConfig } from "@actiondock/core";
3
+ import { startMcpHttpServer, startMcpStdio } from "@actiondock/mcp";
4
+ import { Command } from "commander";
5
+ import { parseByteSize } from "../utils/bytes";
6
+ import { parseDuration } from "../utils/duration";
7
+
8
+ function parseListOption(val: string, prev: string[] = []): string[] {
9
+ const parts = val.split(",").map((s) => s.trim()).filter(Boolean);
10
+ return [...prev, ...parts];
11
+ }
12
+
13
+ export function registerMcpCommands(program: Command): void {
14
+ const mcpCommand = program
15
+ .command("mcp")
16
+ .description("Model Context Protocol (MCP) server for ActionDock Actions (STDIO default)")
17
+ .option(
18
+ "-d, --dir <path>",
19
+ "Project root directory or directories (can be specified multiple times or comma-separated)",
20
+ parseListOption,
21
+ []
22
+ )
23
+ .option(
24
+ "--package <package-id>",
25
+ "Specific linked package ID(s) to serve (can be specified multiple times or comma-separated)",
26
+ parseListOption,
27
+ []
28
+ )
29
+ .option("--all", "Serve all linked packages from global registry")
30
+ .option("--timeout <duration>", "Execution timeout (e.g. 30s, 5m, 500ms)")
31
+ .action(async (options) => {
32
+ // Default: stdio mode
33
+ let timeoutMs: number | undefined;
34
+ if (options.timeout) {
35
+ try {
36
+ timeoutMs = parseDuration(options.timeout);
37
+ } catch (err: any) {
38
+ process.stderr.write(`Error: ${err.message}\n`);
39
+ process.exit(1);
40
+ }
41
+ }
42
+
43
+ const projectRoots =
44
+ options.dir && options.dir.length > 0
45
+ ? options.dir.map((d: string) => resolve(d))
46
+ : undefined;
47
+ const packageIds =
48
+ options.package && options.package.length > 0 ? options.package : undefined;
49
+ const all = Boolean(options.all);
50
+
51
+ try {
52
+ await startMcpStdio({
53
+ projectRoots,
54
+ packageIds,
55
+ all,
56
+ timeoutMs,
57
+ });
58
+ } catch (err: any) {
59
+ process.stderr.write(`Failed to start MCP STDIO server: ${err.message}\n`);
60
+ process.exit(1);
61
+ }
62
+ });
63
+
64
+ mcpCommand
65
+ .command("serve")
66
+ .description("Start the ActionDock MCP server over HTTP transport")
67
+ .option("-p, --port <port>", "Port to listen on (default: 5178)", "5178")
68
+ .option("-H, --host <host>", "Host address to bind to (default: 127.0.0.1)", "127.0.0.1")
69
+ .option("-t, --token <token>", "Authentication token for securing the endpoint (or set ACTIONDOCK_TOKEN)")
70
+ .option("--token-env <env>", "Environment variable name containing the authentication token")
71
+ .option("--allow-insecure-no-auth", "Allow non-loopback host binding without authentication token (INSECURE)")
72
+ .option(
73
+ "--cors-origin <origin>",
74
+ "Allowed CORS origin (can be specified multiple times)",
75
+ (val: string, prev: string[] = []) => [...prev, val],
76
+ []
77
+ )
78
+ .option("--max-body <size>", "Maximum allowed JSON request body size (e.g. 1mb, 500kb)", "1mb")
79
+ .option(
80
+ "-d, --dir <path>",
81
+ "Project root directory or directories (can be specified multiple times or comma-separated)",
82
+ parseListOption,
83
+ []
84
+ )
85
+ .option(
86
+ "--package <package-id>",
87
+ "Specific linked package ID(s) to serve (can be specified multiple times or comma-separated)",
88
+ parseListOption,
89
+ []
90
+ )
91
+ .option("--all", "Serve all linked packages from global registry")
92
+ .option("--timeout <duration>", "Execution timeout (e.g. 30s, 5m, 500ms)")
93
+ .action(async (options) => {
94
+ const port = parseInt(options.port, 10) || 5178;
95
+ const host = options.host || "127.0.0.1";
96
+ const token =
97
+ options.token ||
98
+ (options.tokenEnv ? process.env[options.tokenEnv] : undefined) ||
99
+ process.env.ACTIONDOCK_TOKEN;
100
+ const allowInsecureNoAuth = Boolean(options.allowInsecureNoAuth);
101
+ const corsOrigins =
102
+ options.corsOrigin && options.corsOrigin.length > 0 ? options.corsOrigin : undefined;
103
+
104
+ let maxBodyBytes: number | undefined;
105
+ if (options.maxBody) {
106
+ try {
107
+ maxBodyBytes = parseByteSize(options.maxBody);
108
+ } catch (err: any) {
109
+ console.error(`Error: ${err.message}`);
110
+ process.exit(1);
111
+ }
112
+ }
113
+
114
+ let timeoutMs: number | undefined;
115
+ if (options.timeout) {
116
+ try {
117
+ timeoutMs = parseDuration(options.timeout);
118
+ } catch (err: any) {
119
+ console.error(`Error: ${err.message}`);
120
+ process.exit(1);
121
+ }
122
+ }
123
+
124
+ const projectRoots =
125
+ options.dir && options.dir.length > 0
126
+ ? options.dir.map((d: string) => resolve(d))
127
+ : undefined;
128
+ const packageIds =
129
+ options.package && options.package.length > 0 ? options.package : undefined;
130
+ const all = Boolean(options.all);
131
+
132
+ let targetDescription = "ActionDock MCP Server";
133
+ if (all) {
134
+ targetDescription = "All Linked Packages (Global Registry Mode)";
135
+ } else if (packageIds && packageIds.length > 1) {
136
+ targetDescription = `Packages: ${packageIds.join(", ")}`;
137
+ } else if (projectRoots && projectRoots.length > 1) {
138
+ targetDescription = `Directories: ${projectRoots.join(", ")}`;
139
+ } else if (projectRoots && projectRoots.length === 1) {
140
+ try {
141
+ const config = loadProjectConfig(projectRoots[0]);
142
+ targetDescription = `${config.name} (${config.id})`;
143
+ } catch {
144
+ targetDescription = projectRoots[0];
145
+ }
146
+ } else if (packageIds && packageIds.length === 1) {
147
+ targetDescription = `Package: ${packageIds[0]}`;
148
+ } else {
149
+ const currentRoot = findProjectRoot(process.cwd());
150
+ if (currentRoot) {
151
+ try {
152
+ const config = loadProjectConfig(currentRoot);
153
+ targetDescription = `${config.name} (${config.id})`;
154
+ } catch {
155
+ targetDescription = currentRoot;
156
+ }
157
+ }
158
+ }
159
+
160
+ try {
161
+ const server = startMcpHttpServer({
162
+ port,
163
+ host,
164
+ token,
165
+ allowInsecureNoAuth,
166
+ corsOrigins,
167
+ maxBodyBytes,
168
+ projectRoots,
169
+ packageIds,
170
+ all,
171
+ timeoutMs,
172
+ });
173
+
174
+ console.log(`\n======================================================`);
175
+ console.log(` ActionDock 2.0 MCP HTTP Server`);
176
+ console.log(`======================================================`);
177
+ console.log(` * Listening on: http://${host}:${server.port}`);
178
+ console.log(` * MCP Endpoint: http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${server.port}/mcp`);
179
+ console.log(` * Target: ${targetDescription}`);
180
+ console.log(` * Authentication: ${token ? "Bearer Token Enabled" : "Disabled (Local)"}`);
181
+ console.log(` * CORS Origins: ${corsOrigins ? corsOrigins.join(", ") : "Disabled (Default)"}`);
182
+ console.log(`======================================================\n`);
183
+ console.log(`Press Ctrl+C to terminate.\n`);
184
+
185
+ process.on("SIGINT", () => {
186
+ console.log("\nStopping MCP HTTP server...");
187
+ server.stop();
188
+ process.exit(0);
189
+ });
190
+ process.on("SIGTERM", () => {
191
+ server.stop();
192
+ process.exit(0);
193
+ });
194
+ } catch (err: any) {
195
+ console.error(`Failed to start ActionDock MCP HTTP server: ${err.message}`);
196
+ process.exit(1);
197
+ }
198
+ });
199
+ }
@@ -0,0 +1,359 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ filterWithFallbackInfo,
5
+ findProjectRoot,
6
+ listLinkedPackages,
7
+ loadActions,
8
+ loadPlaybooks,
9
+ loadProjectConfig,
10
+ resolvePlaybookProject,
11
+ } from "@actiondock/core";
12
+ import type { PlaybookDefinition } from "@actiondock/core";
13
+ import { Command } from "commander";
14
+ import { resolveIntent } from "../utils/filter";
15
+
16
+ export function registerPlaybookCommands(program: Command): void {
17
+ const pbCmd = program
18
+ .command("playbook")
19
+ .description("Manage task Playbooks (Task SOPs for AI Agents)");
20
+
21
+ // playbook list
22
+ pbCmd
23
+ .command("list [patterns...]")
24
+ .description("List playbooks in current project or linked packages")
25
+ .option("-i, --intent <pattern>", "Regex or fuzzy intent filter; falls back to full list when no match")
26
+ .option("--no-fallback", "Disable fallback to full list when no items match intent")
27
+ .option("--json", "Output as JSON")
28
+ .action((patterns, options) => {
29
+ try {
30
+ const effectiveIntent = resolveIntent(options.intent, patterns);
31
+ const shouldFallback = options.fallback !== false;
32
+
33
+ const root = findProjectRoot();
34
+ if (root) {
35
+ const config = loadProjectConfig(root);
36
+ const playbooks = loadPlaybooks(root, config.playbooksDir);
37
+ const rawList = Array.from(playbooks.values()).map((p) => ({
38
+ id: p.id,
39
+ description: p.description || "",
40
+ actions: p.actions || [],
41
+ file: p.filePath,
42
+ packageId: config.id,
43
+ }));
44
+
45
+ const filterRes = filterWithFallbackInfo(
46
+ rawList,
47
+ effectiveIntent,
48
+ [(p) => p.id, (p) => p.description, (p) => p.actions, (p) => p.file],
49
+ shouldFallback
50
+ );
51
+
52
+ if (options.json) {
53
+ console.log(JSON.stringify(filterRes.items, null, 2));
54
+ } else {
55
+ console.log(`Playbooks in ${config.id} (${root}):\n`);
56
+ if (filterRes.isFallback && effectiveIntent) {
57
+ console.log(`(No playbooks matched intent '${effectiveIntent}', showing all playbooks)\n`);
58
+ }
59
+ for (const p of filterRes.items) {
60
+ console.log(` ${p.id.padEnd(24)} ${p.description}`);
61
+ }
62
+ }
63
+ } else {
64
+ // List playbooks across all linked packages
65
+ const linkedList = listLinkedPackages();
66
+ if (linkedList.length === 0) {
67
+ console.log("No ActionDock project in current directory, and no packages linked.");
68
+ console.log("Run 'ac link' inside an Action package to register it.");
69
+ return;
70
+ }
71
+
72
+ const aggregated: Array<{
73
+ packageId: string;
74
+ packageName: string;
75
+ path: string;
76
+ playbooks: Array<{
77
+ id: string;
78
+ description: string;
79
+ actions: string[];
80
+ file: string;
81
+ }>;
82
+ }> = [];
83
+
84
+ for (const pkg of linkedList) {
85
+ if (!existsSync(pkg.path)) continue;
86
+ try {
87
+ const config = loadProjectConfig(pkg.path);
88
+ const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
89
+ const pkgPlaybooks = Array.from(playbooks.values()).map((p) => ({
90
+ id: p.id,
91
+ description: p.description || "",
92
+ actions: p.actions || [],
93
+ file: p.filePath,
94
+ }));
95
+
96
+ aggregated.push({
97
+ packageId: pkg.id,
98
+ packageName: pkg.name,
99
+ path: pkg.path,
100
+ playbooks: pkgPlaybooks,
101
+ });
102
+ } catch {
103
+ // Ignore broken linked package
104
+ }
105
+ }
106
+
107
+ let filteredPackages: typeof aggregated = [];
108
+ if (!effectiveIntent) {
109
+ filteredPackages = aggregated;
110
+ } else {
111
+ for (const pkg of aggregated) {
112
+ const pkgMatches = filterWithFallbackInfo(
113
+ [pkg],
114
+ effectiveIntent,
115
+ [(p) => p.packageId, (p) => p.packageName, (p) => p.path],
116
+ false
117
+ ).matchedCount > 0;
118
+
119
+ if (pkgMatches) {
120
+ filteredPackages.push(pkg);
121
+ } else {
122
+ const matchedPlaybooks = filterWithFallbackInfo(
123
+ pkg.playbooks,
124
+ effectiveIntent,
125
+ [(p) => p.id, (p) => p.description, (p) => p.actions, (p) => p.file],
126
+ false
127
+ ).items;
128
+
129
+ if (matchedPlaybooks.length > 0) {
130
+ filteredPackages.push({
131
+ ...pkg,
132
+ playbooks: matchedPlaybooks,
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ if (filteredPackages.length === 0 && shouldFallback) {
139
+ filteredPackages = aggregated;
140
+ }
141
+ }
142
+
143
+ if (options.json) {
144
+ console.log(JSON.stringify(filteredPackages, null, 2));
145
+ } else {
146
+ console.log("Playbooks in Linked Packages:\n");
147
+ for (const pkg of filteredPackages) {
148
+ console.log(`* Package: ${pkg.packageId} (${pkg.path})`);
149
+ if (pkg.playbooks.length === 0) {
150
+ console.log(" (No playbooks)");
151
+ } else {
152
+ for (const p of pkg.playbooks) {
153
+ console.log(` - ${p.id.padEnd(26)} ${p.description}`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ } catch (err: any) {
160
+ console.error(`Error: ${err.message}`);
161
+ process.exit(1);
162
+ }
163
+ });
164
+
165
+ // playbook create / new
166
+ pbCmd
167
+ .command("create <id>")
168
+ .alias("new")
169
+ .description("Scaffold a new Playbook markdown file")
170
+ .option("-d, --desc <description>", "Playbook description")
171
+ .option("-a, --actions <actions...>", "Referenced action IDs")
172
+ .option("-f, --file <filePath>", "Target file path relative to playbooks dir")
173
+ .action((id, options) => {
174
+ const root = findProjectRoot();
175
+ if (!root) {
176
+ console.error("Error: Not in an ActionDock project (actiondock.json not found)");
177
+ process.exit(1);
178
+ }
179
+ try {
180
+ const config = loadProjectConfig(root);
181
+ const pbDir = resolve(root, config.playbooksDir || "playbooks");
182
+ if (!existsSync(pbDir)) {
183
+ mkdirSync(pbDir, { recursive: true });
184
+ }
185
+
186
+ const cleanName = id.replace(/[^a-zA-Z0-9-_]/g, "-");
187
+ const targetRelFile = options.file || `${cleanName}.md`;
188
+ const targetFullFile = resolve(pbDir, targetRelFile);
189
+
190
+ if (existsSync(targetFullFile)) {
191
+ console.error(`Error: File '${targetFullFile}' already exists`);
192
+ process.exit(1);
193
+ }
194
+
195
+ mkdirSync(dirname(targetFullFile), { recursive: true });
196
+
197
+ const desc = options.desc || `SOP guide for ${id}`;
198
+ const actionsList = Array.isArray(options.actions) ? options.actions : [];
199
+ const actionsYaml =
200
+ actionsList.length > 0
201
+ ? `actions:\n${actionsList.map((a: string) => ` - ${a}`).join("\n")}\n`
202
+ : "actions: []\n";
203
+
204
+ const template = `---
205
+ id: ${id}
206
+ description: ${desc}
207
+ ${actionsYaml}---
208
+
209
+ # ${id.replace(/[-_]/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase())} SOP
210
+
211
+ This playbook provides task execution guidance for AI Agents.
212
+
213
+ ## Instructions
214
+
215
+ 1. Inspect available actions with \`<binary> list --json\`.
216
+ 2. Follow the required steps to complete the task.
217
+ `;
218
+
219
+ writeFileSync(targetFullFile, template, "utf-8");
220
+ console.log(`[OK] Created Playbook '${id}' at ${targetFullFile}`);
221
+ } catch (err: any) {
222
+ console.error(`Error: ${err.message}`);
223
+ process.exit(1);
224
+ }
225
+ });
226
+
227
+ // playbook show
228
+ pbCmd
229
+ .command("show <id>")
230
+ .description("Show playbook content and metadata (from current project or linked packages)")
231
+ .option("--json", "Output as JSON")
232
+ .action((id, options) => {
233
+ try {
234
+ const resolved = resolvePlaybookProject(id);
235
+ const pb = resolved.playbook;
236
+
237
+ if (options.json) {
238
+ console.log(JSON.stringify({ ...pb, packageId: resolved.packageId }, null, 2));
239
+ } else {
240
+ console.log(`Playbook: ${pb.id} (Package: ${resolved.packageId})`);
241
+ if (pb.description) console.log(`Description: ${pb.description}`);
242
+ if (pb.actions && pb.actions.length > 0) {
243
+ console.log(`Actions: ${pb.actions.join(", ")}`);
244
+ }
245
+ console.log(`File: ${pb.filePath}\n`);
246
+ console.log("--- Content ---");
247
+ console.log(pb.content);
248
+ }
249
+ } catch (err: any) {
250
+ console.error(`Error: ${err.message}`);
251
+ process.exit(1);
252
+ }
253
+ });
254
+
255
+ // playbook validate
256
+ pbCmd
257
+ .command("validate [id]")
258
+ .description("Validate playbook format and action references (in current project or linked packages)")
259
+ .option("--json", "Output as JSON")
260
+ .action(async (id, options) => {
261
+ try {
262
+ const root = findProjectRoot();
263
+ const targets: Array<{ root: string; packageId: string; playbooks: PlaybookDefinition[] }> = [];
264
+
265
+ if (root) {
266
+ const config = loadProjectConfig(root);
267
+ const playbooks = loadPlaybooks(root, config.playbooksDir);
268
+ if (id) {
269
+ const pb = playbooks.get(id);
270
+ if (!pb) {
271
+ console.error(`Error: Playbook '${id}' not found in current project`);
272
+ process.exit(1);
273
+ }
274
+ targets.push({ root, packageId: config.id, playbooks: [pb] });
275
+ } else {
276
+ targets.push({ root, packageId: config.id, playbooks: Array.from(playbooks.values()) });
277
+ }
278
+ } else if (id) {
279
+ const resolved = resolvePlaybookProject(id);
280
+ targets.push({
281
+ root: resolved.projectRoot,
282
+ packageId: resolved.packageId,
283
+ playbooks: [resolved.playbook],
284
+ });
285
+ } else {
286
+ // Outside project: validate all linked packages
287
+ const linkedList = listLinkedPackages();
288
+ if (linkedList.length === 0) {
289
+ console.error("Error: Not in an ActionDock project, and no packages linked.");
290
+ process.exit(1);
291
+ }
292
+ for (const pkg of linkedList) {
293
+ if (!existsSync(pkg.path)) continue;
294
+ try {
295
+ const config = loadProjectConfig(pkg.path);
296
+ const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
297
+ targets.push({
298
+ root: pkg.path,
299
+ packageId: pkg.id,
300
+ playbooks: Array.from(playbooks.values()),
301
+ });
302
+ } catch {}
303
+ }
304
+ }
305
+
306
+ const results: Array<{ id: string; packageId: string; valid: boolean; warnings: string[]; errors: string[] }> = [];
307
+
308
+ for (const target of targets) {
309
+ const config = loadProjectConfig(target.root);
310
+ const actions = await loadActions(target.root, config.actionsDir);
311
+
312
+ for (const pb of target.playbooks) {
313
+ if (!pb) continue;
314
+ const errors: string[] = [];
315
+ const warnings: string[] = [];
316
+
317
+ if (!pb.id) errors.push("Missing playbook id");
318
+ if (!pb.content) warnings.push("Playbook content is empty");
319
+
320
+ if (pb.actions) {
321
+ for (const actId of pb.actions) {
322
+ if (!actions.has(actId)) {
323
+ warnings.push(`Referenced action '${actId}' not found in package '${target.packageId}'`);
324
+ }
325
+ }
326
+ }
327
+
328
+ results.push({
329
+ id: pb.id,
330
+ packageId: target.packageId,
331
+ valid: errors.length === 0,
332
+ warnings,
333
+ errors,
334
+ });
335
+ }
336
+ }
337
+
338
+ const allValid = results.every((r) => r.valid);
339
+ if (options.json) {
340
+ console.log(JSON.stringify({ valid: allValid, results }, null, 2));
341
+ } else {
342
+ for (const r of results) {
343
+ const prefix = targets.length > 1 || !root ? `[${r.packageId}] ` : "";
344
+ if (r.valid) {
345
+ const warn = r.warnings.length > 0 ? ` (Warnings: ${r.warnings.join("; ")})` : "";
346
+ console.log(`[OK] ${prefix}${r.id}: Valid${warn}`);
347
+ } else {
348
+ console.log(`[FAIL] ${prefix}${r.id}: ${r.errors.join("; ")}`);
349
+ }
350
+ }
351
+ }
352
+ if (!allValid) process.exit(1);
353
+ } catch (err: any) {
354
+ console.error(`Error: ${err.message}`);
355
+ process.exit(1);
356
+ }
357
+ });
358
+ }
359
+