@fcannizzaro/exocommand 1.0.10 → 1.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.
package/README.md CHANGED
@@ -13,43 +13,78 @@
13
13
 
14
14
  ## Overview
15
15
 
16
- Exocommand lets you define a curated set of shell commands in a simple YAML file and makes them available to any [MCP](https://modelcontextprotocol.io)-compatible AI client. Instead of giving an AI agent unrestricted terminal access, you control exactly which commands it can discover and execute.
16
+ Exocommand is a centralized [MCP](https://modelcontextprotocol.io) server that manages multiple projects, each with its own set of shell commands defined in a `.exocommand` YAML file. Instead of giving an AI agent unrestricted terminal access, you register projects with the server and control exactly which commands each agent can discover and execute.
17
17
 
18
18
  ## Features
19
19
 
20
- - **YAML configuration** -- Define commands in a `.exocommand` file with a name, description, and shell command.
21
- - **Live reload** -- The server watches the config file for changes and notifies connected clients automatically.
20
+ - **Multi-project registry** -- Register multiple projects, each with a unique access key. The server manages them all from a single process.
21
+ - **TUI dashboard** -- When running in a terminal, the server displays a real-time dashboard with project tabs, execution cards, status indicators, and animated spinners.
22
+ - **YAML configuration** -- Define commands per project in a `.exocommand` file with a name, description, and shell command.
23
+ - **Live reload** -- The server watches each project's config file for changes and notifies connected clients automatically.
22
24
  - **Streaming output** -- By default, stdout and stderr are streamed line-by-line to the client via SSE in real time. If the server crashes mid-execution, the client retains all lines already received.
23
25
  - **Task mode** -- Opt-in execution mode backed by the MCP experimental tasks API for crash-resilient, independently-pollable command execution.
24
26
  - **Cancellation** -- Long-running commands can be cancelled by the client; the spawned process is killed immediately.
25
- - **Multi-session** -- Uses the Streamable HTTP transport, supporting multiple concurrent MCP sessions.
26
- - **Configurable port** -- Set via the config file, the `EXO_PORT` environment variable, or defaults to `5555`.
27
+ - **Multi-session** -- Uses the Streamable HTTP transport, supporting multiple concurrent MCP sessions across projects.
27
28
 
28
- ## Usage
29
+ <p align="center">
30
+ <img src="media/tui.webp" alt="TUI Dashboard" />
31
+ </p>
32
+
33
+ ## Quick Start
34
+
35
+ 1. **Create a config file** in your project directory (skip if you already have one):
36
+
37
+ ```bash
38
+ bunx @fcannizzaro/exocommand init
39
+ ```
29
40
 
30
- Run the server in your project directory:
41
+ 2. **Register the project** by pointing to a directory containing a `.exocommand` file (or to the file directly):
42
+
43
+ ```bash
44
+ bunx @fcannizzaro/exocommand add .
45
+ ```
46
+
47
+ This validates the config and prints an access key:
48
+
49
+ ```
50
+ ✓ Project registered
51
+
52
+ Key a1b2c3d4e5f6
53
+ Header exocommand-project: a1b2c3d4e5f6
54
+ Config /path/to/project/.exocommand
55
+ ```
56
+
57
+ 3. **Start the server:**
31
58
 
32
59
  ```bash
33
60
  bunx @fcannizzaro/exocommand
34
61
  ```
35
62
 
36
- > use `@fcannizzaro/exocommand@latest` to always run the latest version from npm
63
+ > Use `@fcannizzaro/exocommand@latest` to always run the latest version from npm.
37
64
 
38
65
  The server starts on `http://127.0.0.1:5555/mcp` by default.
39
66
 
40
- > If no `.exocommand` file exists, the CLI will create a sample configuration file and exit. Edit the generated file with your own commands, then run the CLI again to start the server.
67
+ 4. **Connect an MCP client** with the `exocommand-project` header set to the access key (see [Connecting an AI Client](#connecting-an-ai-client)).
41
68
 
42
- ## Configuration
69
+ ## CLI Commands
43
70
 
44
- Create a `.exocommand` file in the project root (or set a custom path with the `EXO_COMMAND_FILE` env var):
71
+ | Command | Description |
72
+ | --- | --- |
73
+ | `exocommand` | Start the MCP server. |
74
+ | `exocommand init` | Create a sample `.exocommand` config file in the current directory. Errors if the file already exists. |
75
+ | `exocommand add <path>` | Register a project. Accepts a directory (auto-resolves `.exocommand` inside it) or a direct file path. Validates the config and prints the access key. |
76
+ | `exocommand ls` | List all registered projects with their access keys and config paths. Missing configs are flagged. |
77
+ | `exocommand rm <key-or-path>` | Remove a project by its access key or filesystem path. |
45
78
 
46
- ```yaml
47
- # Optional: override the default port
48
- port: 4444
79
+ - The registry is stored at `~/.exocommand/exocommand.db.json`.
80
+ - Access keys are 12-character hex strings.
81
+ - Re-adding an already registered path returns the existing key (no duplicates).
82
+
83
+ ## Configuration
49
84
 
50
- # Optional: enable task-based execution mode (default: false)
51
- # taskMode: true
85
+ Create a `.exocommand` file in the project root:
52
86
 
87
+ ```yaml
53
88
  build:
54
89
  description: "Run the production build"
55
90
  command: "cargo build --release"
@@ -64,21 +99,20 @@ list-external:
64
99
  cwd: ../
65
100
  ```
66
101
 
67
- Each top-level key (except reserved keys like `port` and `taskMode`) is a command name. Names may contain letters, numbers, hyphens, and underscores.
102
+ Each top-level key is a command name. Names may contain letters, numbers, hyphens, and underscores.
68
103
 
69
104
  | Field | Required | Description |
70
105
  | --- | --- | --- |
71
106
  | `description` | Yes | What the command does. |
72
107
  | `command` | Yes | The shell command to run. |
73
- | `cwd` | No | Working directory for the command. Relative paths are resolved from the server's working directory. |
108
+ | `cwd` | No | Working directory for the command. Relative paths are resolved from the config file's directory. |
74
109
 
75
110
  ### Environment Variables
76
111
 
77
112
  | Variable | Description | Default |
78
113
  | --- | --- | --- |
79
- | `EXO_COMMAND_FILE` | Path to the `.exocommand` config file | `./.exocommand` |
80
- | `EXO_PORT` | Server port (overrides config file) | `5555` |
81
- | `EXO_TASK_MODE` | Enable task mode (`true` or `1`, overrides config file) | `false` |
114
+ | `EXO_PORT` | Server port | `5555` |
115
+ | `EXO_TASK_MODE` | Enable task mode (`true` or `1`) | `false` |
82
116
 
83
117
  ### Execution Modes
84
118
 
@@ -86,24 +120,13 @@ The `execute` tool supports two execution modes:
86
120
 
87
121
  **Streaming (default)** -- Each output line is sent as an SSE event on the response stream as it happens. The client receives lines in real time. If the server crashes mid-execution, all lines sent up to that point are already with the client. Cancellation works through the standard MCP request signal (client disconnect or `notifications/cancelled`).
88
122
 
89
- **Task mode** -- Enabled via `taskMode: true` in the config file or `EXO_TASK_MODE=true`. Uses the MCP experimental tasks API. The server creates a background task, and clients can poll its status independently. Task-aware clients get full crash resilience (disconnect, reconnect, and resume polling). Supports structured cancellation via `tasks/cancel`.
90
-
91
- ## Safety
92
-
93
- Remember to mount the `.exocommand` as read-only volume on Docker, to prevent the agent from modifying it.
94
-
95
- If you have already use a script to start containerized agents, you can modify it to automatically mount the `.exocommand` file when present in the launch directory:
96
-
97
- ```bash
98
- docker run \
99
- # ...
100
- $([ -f "$PWD/.exocommand" ] && echo "-v $PWD/.exocommand:$PWD/.exocommand:ro") \
101
- # ...
102
- ```
123
+ **Task mode** -- Enabled via `EXO_TASK_MODE=true`. Uses the MCP experimental tasks API. The server creates a background task, and clients can poll its status independently. Task-aware clients get full crash resilience (disconnect, reconnect, and resume polling). Supports structured cancellation via `tasks/cancel`.
103
124
 
104
125
  ## Connecting an AI Client
105
126
 
106
- Point any MCP-compatible client at the server's `/mcp` endpoint. For example, with [OpenCode](https://opencode.ai):
127
+ Point any MCP-compatible client at the server's `/mcp` endpoint. Clients must include the `exocommand-project` header with the project's access key on the initialize request.
128
+
129
+ For example, with [OpenCode](https://opencode.ai):
107
130
 
108
131
  ```json
109
132
  {
@@ -111,7 +134,10 @@ Point any MCP-compatible client at the server's `/mcp` endpoint. For example, wi
111
134
  "exocommand": {
112
135
  "enabled": true,
113
136
  "type": "remote",
114
- "url": "http://host.docker.internal:5555/mcp"
137
+ "url": "http://host.docker.internal:5555/mcp",
138
+ "headers": {
139
+ "exocommand-project": "<access-key>"
140
+ }
115
141
  }
116
142
  }
117
143
  }
@@ -121,7 +147,7 @@ The server exposes two tools:
121
147
 
122
148
  | Tool | Description |
123
149
  | --- | --- |
124
- | `listCommands()` | Returns all available commands from the config file. |
150
+ | `listCommands()` | Returns all available commands from the project's config file. |
125
151
  | `execute(name, timeout?)` | Executes a command by name, streaming output back to the client. An optional `timeout` (in seconds) kills the process after the given duration and returns the buffered output. |
126
152
 
127
153
  Remember to tell the agent that they can use these tools to run commands on the project. For example:
@@ -130,6 +156,19 @@ Remember to tell the agent that they can use these tools to run commands on the
130
156
  You can run predefined shell commands using the `listCommands()` and `execute(name, timeout?)` tools. Use `listCommands()` to see all available commands, and `execute(name, timeout?)` to run a specific command, with an optional timeout in seconds.
131
157
  ```
132
158
 
159
+ ## Safety
160
+
161
+ When running agents in Docker, mount `.exocommand` files as read-only volumes to prevent the agent from modifying them.
162
+
163
+ If you use a script to start containerized agents, you can automatically mount the `.exocommand` file when present in the launch directory:
164
+
165
+ ```bash
166
+ docker run \
167
+ # ...
168
+ $([ -f "$PWD/.exocommand" ] && echo "-v $PWD/.exocommand:$PWD/.exocommand:ro") \
169
+ # ...
170
+ ```
171
+
133
172
  ## License
134
173
 
135
174
  [MIT](LICENSE)
package/dist/cli.js ADDED
@@ -0,0 +1,29 @@
1
+ export function parseArgs(argv) {
2
+ const args = argv.slice(2);
3
+ if (args.length === 0) {
4
+ return { kind: "serve" };
5
+ }
6
+ const subcommand = args[0];
7
+ switch (subcommand) {
8
+ case "add": {
9
+ const target = args[1];
10
+ if (!target) {
11
+ throw new Error("Usage: exocommand add <path-to-.exocommand-or-directory>");
12
+ }
13
+ return { kind: "add", path: target };
14
+ }
15
+ case "ls":
16
+ return { kind: "ls" };
17
+ case "init":
18
+ return { kind: "init" };
19
+ case "rm": {
20
+ const target = args[1];
21
+ if (!target) {
22
+ throw new Error("Usage: exocommand rm <access-key-or-path>");
23
+ }
24
+ return { kind: "rm", target };
25
+ }
26
+ default:
27
+ throw new Error(`Unknown subcommand "${subcommand}". Available: add, init, ls, rm`);
28
+ }
29
+ }
@@ -0,0 +1,45 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseArgs } from "./cli";
3
+ describe("parseArgs", () => {
4
+ test("no args returns serve action", () => {
5
+ const result = parseArgs(["node", "exocommand"]);
6
+ expect(result).toEqual({ kind: "serve" });
7
+ });
8
+ test("add with path returns add action", () => {
9
+ const result = parseArgs(["node", "exocommand", "add", "."]);
10
+ expect(result).toEqual({ kind: "add", path: "." });
11
+ });
12
+ test("add with absolute path returns add action", () => {
13
+ const result = parseArgs(["node", "exocommand", "add", "/home/user/project/.exocommand"]);
14
+ expect(result).toEqual({ kind: "add", path: "/home/user/project/.exocommand" });
15
+ });
16
+ test("add without path throws", () => {
17
+ expect(() => parseArgs(["node", "exocommand", "add"])).toThrow("Usage: exocommand add <path-to-.exocommand-or-directory>");
18
+ });
19
+ test("ls returns ls action", () => {
20
+ const result = parseArgs(["node", "exocommand", "ls"]);
21
+ expect(result).toEqual({ kind: "ls" });
22
+ });
23
+ test("rm with key returns rm action", () => {
24
+ const result = parseArgs(["node", "exocommand", "rm", "a1b2c3d4e5f6"]);
25
+ expect(result).toEqual({ kind: "rm", target: "a1b2c3d4e5f6" });
26
+ });
27
+ test("rm with path returns rm action", () => {
28
+ const result = parseArgs(["node", "exocommand", "rm", "."]);
29
+ expect(result).toEqual({ kind: "rm", target: "." });
30
+ });
31
+ test("rm with absolute path returns rm action", () => {
32
+ const result = parseArgs(["node", "exocommand", "rm", "/home/user/project"]);
33
+ expect(result).toEqual({ kind: "rm", target: "/home/user/project" });
34
+ });
35
+ test("rm without target throws", () => {
36
+ expect(() => parseArgs(["node", "exocommand", "rm"])).toThrow("Usage: exocommand rm <access-key-or-path>");
37
+ });
38
+ test("init returns init action", () => {
39
+ const result = parseArgs(["node", "exocommand", "init"]);
40
+ expect(result).toEqual({ kind: "init" });
41
+ });
42
+ test("unknown subcommand throws", () => {
43
+ expect(() => parseArgs(["node", "exocommand", "unknown"])).toThrow('Unknown subcommand "unknown". Available: add, init, ls, rm');
44
+ });
45
+ });
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile, access } from "node:fs/promises";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
2
3
  import { parse as parseYaml } from "yaml";
3
4
  const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
4
5
  const RESERVED_KEYS = new Set(["port", "taskMode"]);
@@ -51,11 +52,17 @@ export async function loadConfig(filePath) {
51
52
  throw new Error(`Invalid command "${name}": "cwd" must be a string`);
52
53
  }
53
54
  const { description, command, cwd } = value;
55
+ // Resolve relative cwd against the config file's directory
56
+ const resolvedCwd = cwd
57
+ ? isAbsolute(cwd)
58
+ ? cwd
59
+ : resolve(dirname(filePath), cwd)
60
+ : undefined;
54
61
  config.commands.push({
55
62
  name,
56
63
  description,
57
64
  command: command.trim(),
58
- ...(cwd ? { cwd } : {}),
65
+ ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
59
66
  });
60
67
  }
61
68
  return config;
@@ -1,6 +1,6 @@
1
1
  import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
2
  import { loadConfig, loadCommands } from "./config";
3
- import { join } from "node:path";
3
+ import { join, dirname, resolve } from "node:path";
4
4
  import { mkdtemp, rm } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  let tmpDir;
@@ -236,6 +236,36 @@ hello:
236
236
  `);
237
237
  expect(loadCommands(path)).rejects.toThrow('"cwd" must be a string');
238
238
  });
239
+ test("resolves relative cwd against config file directory", async () => {
240
+ const path = await writeConfig("cwd-relative.yaml", `
241
+ hello:
242
+ description: "hi"
243
+ command: "echo hi"
244
+ cwd: "../other"
245
+ `);
246
+ const commands = await loadCommands(path);
247
+ expect(commands[0].cwd).toBe(resolve(dirname(path), "../other"));
248
+ });
249
+ test("preserves absolute cwd as-is", async () => {
250
+ const path = await writeConfig("cwd-absolute.yaml", `
251
+ hello:
252
+ description: "hi"
253
+ command: "echo hi"
254
+ cwd: "/usr/local/bin"
255
+ `);
256
+ const commands = await loadCommands(path);
257
+ expect(commands[0].cwd).toBe("/usr/local/bin");
258
+ });
259
+ test("resolves dot cwd to config file directory", async () => {
260
+ const path = await writeConfig("cwd-dot.yaml", `
261
+ hello:
262
+ description: "hi"
263
+ command: "echo hi"
264
+ cwd: "."
265
+ `);
266
+ const commands = await loadCommands(path);
267
+ expect(commands[0].cwd).toBe(dirname(path));
268
+ });
239
269
  });
240
270
  describe("loadConfig taskMode", () => {
241
271
  test("parses taskMode: true", async () => {