@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 +77 -38
- package/dist/cli.js +29 -0
- package/dist/cli.test.js +45 -0
- package/dist/config.js +8 -1
- package/dist/config.test.js +31 -1
- package/dist/index.js +292 -47
- package/dist/logger.js +29 -16
- package/dist/registry.js +80 -0
- package/dist/registry.test.js +107 -0
- package/dist/server.js +20 -10
- package/dist/task.js +14 -4
- package/dist/tui.js +517 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -13,43 +13,78 @@
|
|
|
13
13
|
|
|
14
14
|
## Overview
|
|
15
15
|
|
|
16
|
-
Exocommand
|
|
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
|
-
- **
|
|
21
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
##
|
|
69
|
+
## CLI Commands
|
|
43
70
|
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
80
|
-
| `
|
|
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 `
|
|
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.
|
|
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
|
+
}
|
package/dist/cli.test.js
ADDED
|
@@ -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
|
-
...(
|
|
65
|
+
...(resolvedCwd ? { cwd: resolvedCwd } : {}),
|
|
59
66
|
});
|
|
60
67
|
}
|
|
61
68
|
return config;
|
package/dist/config.test.js
CHANGED
|
@@ -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 () => {
|