@fcannizzaro/exocommand 1.0.0 → 1.0.2

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
@@ -103,6 +103,12 @@ The server exposes two tools:
103
103
  | `listCommands()` | Returns all available commands from the config file. |
104
104
  | `execute(name)` | Executes a command by name, streaming output back to the client. |
105
105
 
106
+ Remember to tell the agent that they can use these tools to run commands on the project. For example:
107
+
108
+ ```
109
+ You can run predefined shell commands using the `listCommands()` and `execute(name)` tools. Use `listCommands()` to see all available commands, and `execute(name)` to run a specific command.
110
+ ```
111
+
106
112
  ## License
107
113
 
108
114
  [MIT](LICENSE)
@@ -0,0 +1,205 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { loadConfig, loadCommands } from "./config";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ let tmpDir;
7
+ beforeAll(async () => {
8
+ tmpDir = await mkdtemp(join(tmpdir(), "exocommand-test-"));
9
+ });
10
+ afterAll(async () => {
11
+ await rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ async function writeConfig(name, content) {
14
+ const path = join(tmpDir, name);
15
+ await Bun.write(path, content);
16
+ return path;
17
+ }
18
+ describe("loadConfig", () => {
19
+ test("parses valid config with commands", async () => {
20
+ const path = await writeConfig("valid.yaml", `
21
+ hello:
22
+ description: "Print hello"
23
+ command: "echo hello"
24
+ build:
25
+ description: "Run build"
26
+ command: "bun run build"
27
+ `);
28
+ const config = await loadConfig(path);
29
+ expect(config.port).toBeUndefined();
30
+ expect(config.commands).toHaveLength(2);
31
+ expect(config.commands[0]).toEqual({
32
+ name: "hello",
33
+ description: "Print hello",
34
+ command: "echo hello",
35
+ });
36
+ expect(config.commands[1]).toEqual({
37
+ name: "build",
38
+ description: "Run build",
39
+ command: "bun run build",
40
+ });
41
+ });
42
+ test("parses valid port", async () => {
43
+ const path = await writeConfig("port-valid.yaml", `
44
+ port: 8080
45
+ hello:
46
+ description: "hi"
47
+ command: "echo hi"
48
+ `);
49
+ const config = await loadConfig(path);
50
+ expect(config.port).toBe(8080);
51
+ });
52
+ test("accepts port boundary values", async () => {
53
+ const pathMin = await writeConfig("port-min.yaml", "port: 1\n");
54
+ const pathMax = await writeConfig("port-max.yaml", "port: 65535\n");
55
+ expect((await loadConfig(pathMin)).port).toBe(1);
56
+ expect((await loadConfig(pathMax)).port).toBe(65535);
57
+ });
58
+ test("rejects port 0", async () => {
59
+ const path = await writeConfig("port-zero.yaml", "port: 0\n");
60
+ expect(loadConfig(path)).rejects.toThrow("Invalid port");
61
+ });
62
+ test("rejects port above 65535", async () => {
63
+ const path = await writeConfig("port-high.yaml", "port: 65536\n");
64
+ expect(loadConfig(path)).rejects.toThrow("Invalid port");
65
+ });
66
+ test("rejects non-numeric port", async () => {
67
+ const path = await writeConfig("port-abc.yaml", 'port: "abc"\n');
68
+ expect(loadConfig(path)).rejects.toThrow("Invalid port");
69
+ });
70
+ test("rejects float port", async () => {
71
+ const path = await writeConfig("port-float.yaml", "port: 3.14\n");
72
+ expect(loadConfig(path)).rejects.toThrow("Invalid port");
73
+ });
74
+ test("port key is not treated as a command", async () => {
75
+ const path = await writeConfig("port-reserved.yaml", `
76
+ port: 3000
77
+ hello:
78
+ description: "hi"
79
+ command: "echo hi"
80
+ `);
81
+ const config = await loadConfig(path);
82
+ expect(config.commands).toHaveLength(1);
83
+ expect(config.commands[0].name).toBe("hello");
84
+ });
85
+ test("accepts valid command names", async () => {
86
+ const path = await writeConfig("names-valid.yaml", `
87
+ hello:
88
+ description: "a"
89
+ command: "echo a"
90
+ my-cmd:
91
+ description: "b"
92
+ command: "echo b"
93
+ test_123:
94
+ description: "c"
95
+ command: "echo c"
96
+ `);
97
+ const config = await loadConfig(path);
98
+ expect(config.commands).toHaveLength(3);
99
+ expect(config.commands.map((c) => c.name)).toEqual([
100
+ "hello",
101
+ "my-cmd",
102
+ "test_123",
103
+ ]);
104
+ });
105
+ test("rejects command name with spaces", async () => {
106
+ const path = await writeConfig("name-space.yaml", `
107
+ "has space":
108
+ description: "x"
109
+ command: "echo x"
110
+ `);
111
+ expect(loadConfig(path)).rejects.toThrow("Invalid command name");
112
+ });
113
+ test("rejects command name with dots", async () => {
114
+ const path = await writeConfig("name-dot.yaml", `
115
+ "has.dot":
116
+ description: "x"
117
+ command: "echo x"
118
+ `);
119
+ expect(loadConfig(path)).rejects.toThrow("Invalid command name");
120
+ });
121
+ test("rejects command missing description", async () => {
122
+ const path = await writeConfig("no-desc.yaml", `
123
+ broken:
124
+ command: "echo x"
125
+ `);
126
+ expect(loadConfig(path)).rejects.toThrow('Invalid command "broken"');
127
+ });
128
+ test("rejects command missing command field", async () => {
129
+ const path = await writeConfig("no-cmd.yaml", `
130
+ broken:
131
+ description: "x"
132
+ `);
133
+ expect(loadConfig(path)).rejects.toThrow('Invalid command "broken"');
134
+ });
135
+ test("rejects command with wrong types", async () => {
136
+ const path = await writeConfig("wrong-type.yaml", `
137
+ broken:
138
+ description: 123
139
+ command: "echo x"
140
+ `);
141
+ expect(loadConfig(path)).rejects.toThrow('Invalid command "broken"');
142
+ });
143
+ test("throws on file not found", async () => {
144
+ expect(loadConfig("/nonexistent/path/.exocommand")).rejects.toThrow("Config file not found");
145
+ });
146
+ test("throws on empty file", async () => {
147
+ const path = await writeConfig("empty.yaml", "");
148
+ expect(loadConfig(path)).rejects.toThrow("expected a YAML mapping");
149
+ });
150
+ test("throws on non-object YAML (string)", async () => {
151
+ const path = await writeConfig("string.yaml", '"hello"');
152
+ expect(loadConfig(path)).rejects.toThrow("expected a YAML mapping");
153
+ });
154
+ test("throws on non-object YAML (number)", async () => {
155
+ const path = await writeConfig("number.yaml", "42");
156
+ expect(loadConfig(path)).rejects.toThrow("expected a YAML mapping");
157
+ });
158
+ test("throws on non-object YAML (array)", async () => {
159
+ const path = await writeConfig("array.yaml", "- one\n- two\n");
160
+ expect(loadConfig(path)).rejects.toThrow("Invalid command");
161
+ });
162
+ test("preserves multiline block scalar command", async () => {
163
+ const path = await writeConfig("multiline.yaml", `
164
+ deploy:
165
+ description: "Deploy"
166
+ command: |
167
+ echo "step 1"
168
+ echo "step 2"
169
+ `);
170
+ const config = await loadConfig(path);
171
+ expect(config.commands[0].command).toBe('echo "step 1"\necho "step 2"');
172
+ });
173
+ test("trims whitespace from command", async () => {
174
+ const path = await writeConfig("trim.yaml", `
175
+ hello:
176
+ description: "hi"
177
+ command: " echo hello "
178
+ `);
179
+ const config = await loadConfig(path);
180
+ expect(config.commands[0].command).toBe("echo hello");
181
+ });
182
+ test("returns empty commands when only port is set", async () => {
183
+ const path = await writeConfig("port-only.yaml", "port: 5000\n");
184
+ const config = await loadConfig(path);
185
+ expect(config.port).toBe(5000);
186
+ expect(config.commands).toHaveLength(0);
187
+ });
188
+ });
189
+ describe("loadCommands", () => {
190
+ test("returns only the commands array", async () => {
191
+ const path = await writeConfig("load-cmds.yaml", `
192
+ port: 3000
193
+ hello:
194
+ description: "hi"
195
+ command: "echo hi"
196
+ `);
197
+ const commands = await loadCommands(path);
198
+ expect(commands).toHaveLength(1);
199
+ expect(commands[0]).toEqual({
200
+ name: "hello",
201
+ description: "hi",
202
+ command: "echo hi",
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,110 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { executeCommand } from "./executor";
3
+ function createLogCollector() {
4
+ const entries = [];
5
+ const log = async (level, logger, data) => {
6
+ entries.push({ level, logger, data });
7
+ };
8
+ return { log, entries };
9
+ }
10
+ describe("executeCommand", () => {
11
+ test("runs a simple command and captures output", async () => {
12
+ const { log, entries } = createLogCollector();
13
+ const ac = new AbortController();
14
+ const result = await executeCommand("echo hello", log, ac.signal);
15
+ expect(result.exitCode).toBe(0);
16
+ expect(result.killed).toBe(false);
17
+ expect(entries).toContainEqual({
18
+ level: "info",
19
+ logger: "stdout",
20
+ data: "hello",
21
+ });
22
+ });
23
+ test("returns non-zero exit code", async () => {
24
+ const { log } = createLogCollector();
25
+ const ac = new AbortController();
26
+ const result = await executeCommand("exit 42", log, ac.signal);
27
+ expect(result.exitCode).toBe(42);
28
+ expect(result.killed).toBe(false);
29
+ });
30
+ test("captures stderr with correct level", async () => {
31
+ const { log, entries } = createLogCollector();
32
+ const ac = new AbortController();
33
+ const result = await executeCommand("echo err >&2", log, ac.signal);
34
+ expect(result.exitCode).toBe(0);
35
+ expect(entries).toContainEqual({
36
+ level: "error",
37
+ logger: "stderr",
38
+ data: "err",
39
+ });
40
+ });
41
+ test("captures mixed stdout and stderr", async () => {
42
+ const { log, entries } = createLogCollector();
43
+ const ac = new AbortController();
44
+ const result = await executeCommand('echo out && echo err >&2', log, ac.signal);
45
+ expect(result.exitCode).toBe(0);
46
+ const stdoutEntries = entries.filter((e) => e.logger === "stdout");
47
+ const stderrEntries = entries.filter((e) => e.logger === "stderr");
48
+ expect(stdoutEntries.length).toBeGreaterThanOrEqual(1);
49
+ expect(stderrEntries.length).toBeGreaterThanOrEqual(1);
50
+ expect(stdoutEntries.some((e) => e.data === "out")).toBe(true);
51
+ expect(stderrEntries.some((e) => e.data === "err")).toBe(true);
52
+ });
53
+ test("produces no log entries for silent command", async () => {
54
+ const { log, entries } = createLogCollector();
55
+ const ac = new AbortController();
56
+ const result = await executeCommand("true", log, ac.signal);
57
+ expect(result.exitCode).toBe(0);
58
+ expect(entries).toHaveLength(0);
59
+ });
60
+ test("captures multiline output as separate entries", async () => {
61
+ const { log, entries } = createLogCollector();
62
+ const ac = new AbortController();
63
+ const result = await executeCommand('echo "line1" && echo "line2" && echo "line3"', log, ac.signal);
64
+ expect(result.exitCode).toBe(0);
65
+ const stdoutData = entries
66
+ .filter((e) => e.logger === "stdout")
67
+ .map((e) => e.data);
68
+ expect(stdoutData).toEqual(["line1", "line2", "line3"]);
69
+ });
70
+ test("returns immediately with pre-aborted signal", async () => {
71
+ const { log, entries } = createLogCollector();
72
+ const ac = new AbortController();
73
+ ac.abort();
74
+ const result = await executeCommand("echo should-not-run", log, ac.signal);
75
+ expect(result.exitCode).toBe(-1);
76
+ expect(result.killed).toBe(true);
77
+ expect(entries).toHaveLength(0);
78
+ });
79
+ test("kills process on abort during execution", async () => {
80
+ const { log } = createLogCollector();
81
+ const ac = new AbortController();
82
+ const promise = executeCommand("while true; do echo tick; sleep 0.1; done", log, ac.signal);
83
+ // Wait a moment for the process to start, then abort
84
+ await new Promise((r) => setTimeout(r, 200));
85
+ ac.abort();
86
+ const result = await promise;
87
+ expect(result.killed).toBe(true);
88
+ }, 10000);
89
+ test("does not log empty lines", async () => {
90
+ const { log, entries } = createLogCollector();
91
+ const ac = new AbortController();
92
+ const result = await executeCommand('echo "a" && echo "" && echo "b"', log, ac.signal);
93
+ expect(result.exitCode).toBe(0);
94
+ const stdoutData = entries
95
+ .filter((e) => e.logger === "stdout")
96
+ .map((e) => e.data);
97
+ expect(stdoutData).toEqual(["a", "b"]);
98
+ });
99
+ test("flushes output without trailing newline", async () => {
100
+ const { log, entries } = createLogCollector();
101
+ const ac = new AbortController();
102
+ const result = await executeCommand("printf 'no-newline'", log, ac.signal);
103
+ expect(result.exitCode).toBe(0);
104
+ expect(entries).toContainEqual({
105
+ level: "info",
106
+ logger: "stdout",
107
+ data: "no-newline",
108
+ });
109
+ });
110
+ });
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { logger } from "./logger.js";
8
8
  const CONFIG_PATH = process.env.EXO_COMMAND_FILE || "./.exocommand";
9
9
  // Load config to get port
10
10
  const config = await loadConfig(CONFIG_PATH);
11
- const PORT = parseInt(process.env.EXO_PORT || String(config.port ?? 3000), 10);
11
+ const PORT = parseInt(process.env.EXO_PORT || String(config.port ?? 5555), 10);
12
12
  const transports = new Map();
13
13
  const servers = new Set();
14
14
  async function handleMcp(req) {
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@fcannizzaro/exocommand",
3
3
  "module": "index.ts",
4
- "version": "1.0.0",
4
+ "description": "An MCP server that exposes user-defined shell commands as tools for AI coding assistants",
5
+ "version": "1.0.2",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
8
+ "homepage": "https://github.com/fcannizzaro/exocommand",
7
9
  "author": {
8
10
  "name": "Francesco Saverio Cannizzaro (fcannizzaro)",
9
11
  "url": "https://fcannizzaro.com"
@@ -27,10 +29,11 @@
27
29
  "LICENSE"
28
30
  ],
29
31
  "scripts": {
30
- "build": "tsc"
32
+ "build": "tsc",
33
+ "test": "bun test"
31
34
  },
32
35
  "bin": {
33
- "exocommand": "./index.js"
36
+ "exocommand": "./dist/index.js"
34
37
  },
35
38
  "devDependencies": {
36
39
  "@types/bun": "latest"