@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/dist/index.js
CHANGED
|
@@ -2,20 +2,122 @@
|
|
|
2
2
|
import { watch } from "node:fs";
|
|
3
3
|
import { createServer as createHttpServer } from "node:http";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { resolve, join, dirname, basename } from "node:path";
|
|
6
|
+
import { stat } from "node:fs/promises";
|
|
5
7
|
import { getRequestListener } from "@hono/node-server";
|
|
6
8
|
import {} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
9
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
8
10
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
-
import {
|
|
11
|
+
import { parseArgs } from "./cli.js";
|
|
10
12
|
import { loadConfig, configExists, createSampleConfig } from "./config.js";
|
|
11
|
-
import { logger } from "./logger.js";
|
|
13
|
+
import { logger, createTuiLogger } from "./logger.js";
|
|
14
|
+
import { addProject, removeProject, removeProjectByPath, listProjects, resolveProject, getRegistryPath, } from "./registry.js";
|
|
15
|
+
import { createServer } from "./server.js";
|
|
16
|
+
import { createTui } from "./tui.js";
|
|
12
17
|
const _require = createRequire(import.meta.url);
|
|
13
18
|
const { version } = _require("../package.json");
|
|
14
|
-
|
|
19
|
+
// ANSI styling for CLI output
|
|
20
|
+
const RESET = "\x1b[0m";
|
|
21
|
+
const BOLD = "\x1b[1m";
|
|
22
|
+
const DIM = "\x1b[2m";
|
|
23
|
+
const CYAN = "\x1b[36m";
|
|
24
|
+
const GREEN = "\x1b[32m";
|
|
25
|
+
const RED = "\x1b[31m";
|
|
26
|
+
const MAGENTA = "\x1b[35m";
|
|
15
27
|
const transports = new Map();
|
|
16
28
|
const servers = new Set();
|
|
29
|
+
// Session -> accessKey mapping
|
|
30
|
+
const sessionProject = new Map();
|
|
31
|
+
// AccessKey -> Set<McpServer> (for targeted config-change notifications)
|
|
32
|
+
const projectServers = new Map();
|
|
33
|
+
// Per-project .exocommand file watchers
|
|
34
|
+
const configWatchers = new Map();
|
|
35
|
+
const configTimers = new Map();
|
|
17
36
|
let nextAgentId = 0;
|
|
18
37
|
let taskMode = false;
|
|
38
|
+
let tui = null;
|
|
39
|
+
let activeLogger = logger;
|
|
40
|
+
// -- Per-project config file watchers -------------------------------------
|
|
41
|
+
function ensureConfigWatcher(accessKey, configPath) {
|
|
42
|
+
if (configWatchers.has(accessKey))
|
|
43
|
+
return;
|
|
44
|
+
const watcher = watch(configPath, (eventType) => {
|
|
45
|
+
if (eventType !== "change")
|
|
46
|
+
return;
|
|
47
|
+
const existing = configTimers.get(accessKey);
|
|
48
|
+
if (existing)
|
|
49
|
+
clearTimeout(existing);
|
|
50
|
+
configTimers.set(accessKey, setTimeout(() => {
|
|
51
|
+
configTimers.delete(accessKey);
|
|
52
|
+
activeLogger.info("watcher", `${configPath} changed, notifying project ${accessKey}`);
|
|
53
|
+
const projectSet = projectServers.get(accessKey);
|
|
54
|
+
if (projectSet) {
|
|
55
|
+
for (const server of projectSet) {
|
|
56
|
+
try {
|
|
57
|
+
server.sendToolListChanged();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// client may have disconnected
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, 200));
|
|
65
|
+
});
|
|
66
|
+
configWatchers.set(accessKey, watcher);
|
|
67
|
+
activeLogger.info("watcher", `watching ${configPath} (project: ${accessKey})`);
|
|
68
|
+
}
|
|
69
|
+
function stopConfigWatcher(accessKey) {
|
|
70
|
+
const watcher = configWatchers.get(accessKey);
|
|
71
|
+
if (watcher) {
|
|
72
|
+
watcher.close();
|
|
73
|
+
configWatchers.delete(accessKey);
|
|
74
|
+
}
|
|
75
|
+
const timer = configTimers.get(accessKey);
|
|
76
|
+
if (timer) {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
configTimers.delete(accessKey);
|
|
79
|
+
}
|
|
80
|
+
activeLogger.info("watcher", `stopped watching project ${accessKey}`);
|
|
81
|
+
}
|
|
82
|
+
// Sync config watchers with the current registry state
|
|
83
|
+
async function syncConfigWatchers() {
|
|
84
|
+
const registry = await listProjects();
|
|
85
|
+
const registeredKeys = new Set(Object.keys(registry));
|
|
86
|
+
// Start watchers for newly registered projects
|
|
87
|
+
for (const [accessKey, configPath] of Object.entries(registry)) {
|
|
88
|
+
ensureConfigWatcher(accessKey, configPath);
|
|
89
|
+
}
|
|
90
|
+
// Stop watchers for projects no longer in the registry
|
|
91
|
+
for (const accessKey of configWatchers.keys()) {
|
|
92
|
+
if (!registeredKeys.has(accessKey)) {
|
|
93
|
+
stopConfigWatcher(accessKey);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// -- Registry file watcher (detect add/rm from other processes) -----------
|
|
98
|
+
function watchRegistry() {
|
|
99
|
+
const registryPath = getRegistryPath();
|
|
100
|
+
let debounceTimer = null;
|
|
101
|
+
try {
|
|
102
|
+
watch(registryPath, (eventType) => {
|
|
103
|
+
if (eventType !== "change")
|
|
104
|
+
return;
|
|
105
|
+
if (debounceTimer)
|
|
106
|
+
clearTimeout(debounceTimer);
|
|
107
|
+
debounceTimer = setTimeout(async () => {
|
|
108
|
+
debounceTimer = null;
|
|
109
|
+
activeLogger.info("registry", "registry changed, reloading project list");
|
|
110
|
+
await syncConfigWatchers();
|
|
111
|
+
}, 200);
|
|
112
|
+
});
|
|
113
|
+
activeLogger.info("registry", "watching exocommand.db.json for changes");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// registry file may not exist yet; it will be created on first add
|
|
117
|
+
activeLogger.warn("registry", "registry file not found, skipping watch");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// -- MCP request handler --------------------------------------------------
|
|
19
121
|
async function handleMcp(req) {
|
|
20
122
|
const url = new URL(req.url);
|
|
21
123
|
if (url.pathname !== "/mcp") {
|
|
@@ -35,33 +137,103 @@ async function handleMcp(req) {
|
|
|
35
137
|
return transports.get(sessionId).handleRequest(req);
|
|
36
138
|
}
|
|
37
139
|
if (req.method === "POST") {
|
|
38
|
-
//
|
|
140
|
+
// Reuse existing session
|
|
39
141
|
if (sessionId && transports.has(sessionId)) {
|
|
40
142
|
return transports.get(sessionId).handleRequest(req);
|
|
41
143
|
}
|
|
42
|
-
//
|
|
144
|
+
// Parse body to check if it's an initialize request
|
|
43
145
|
const body = await req.json();
|
|
44
146
|
if (!sessionId && isInitializeRequest(body)) {
|
|
147
|
+
// Extract project access key from header
|
|
148
|
+
const accessKey = req.headers.get("exocommand-project");
|
|
149
|
+
if (!accessKey) {
|
|
150
|
+
return Response.json({
|
|
151
|
+
jsonrpc: "2.0",
|
|
152
|
+
error: {
|
|
153
|
+
code: -32000,
|
|
154
|
+
message: "Missing exocommand-project header",
|
|
155
|
+
},
|
|
156
|
+
id: null,
|
|
157
|
+
}, { status: 400 });
|
|
158
|
+
}
|
|
159
|
+
// Resolve config path from registry
|
|
160
|
+
let configPath;
|
|
161
|
+
try {
|
|
162
|
+
configPath = await resolveProject(accessKey);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return Response.json({
|
|
166
|
+
jsonrpc: "2.0",
|
|
167
|
+
error: {
|
|
168
|
+
code: -32000,
|
|
169
|
+
message: `Unknown project key: ${accessKey}`,
|
|
170
|
+
},
|
|
171
|
+
id: null,
|
|
172
|
+
}, { status: 400 });
|
|
173
|
+
}
|
|
174
|
+
// Verify the .exocommand file still exists
|
|
175
|
+
if (!(await configExists(configPath))) {
|
|
176
|
+
return Response.json({
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
error: {
|
|
179
|
+
code: -32000,
|
|
180
|
+
message: `Config file not found: ${configPath} (project may have been moved or deleted)`,
|
|
181
|
+
},
|
|
182
|
+
id: null,
|
|
183
|
+
}, { status: 400 });
|
|
184
|
+
}
|
|
45
185
|
const agentId = ++nextAgentId;
|
|
46
|
-
const agentLogger =
|
|
186
|
+
const agentLogger = activeLogger.withAgent(agentId);
|
|
187
|
+
const projectLabel = basename(dirname(configPath));
|
|
47
188
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
48
189
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
49
190
|
onsessioninitialized: (sid) => {
|
|
50
191
|
transports.set(sid, transport);
|
|
51
|
-
|
|
192
|
+
sessionProject.set(sid, accessKey);
|
|
193
|
+
// Track this server under the project
|
|
194
|
+
if (!projectServers.has(accessKey)) {
|
|
195
|
+
projectServers.set(accessKey, new Set());
|
|
196
|
+
}
|
|
197
|
+
const isFirstSession = projectServers.get(accessKey).size === 0;
|
|
198
|
+
projectServers.get(accessKey).add(server);
|
|
199
|
+
// Notify TUI about new project on first session
|
|
200
|
+
if (isFirstSession) {
|
|
201
|
+
tui?.addProject(accessKey, projectLabel);
|
|
202
|
+
}
|
|
203
|
+
// Start watching this project's config if not already watched
|
|
204
|
+
ensureConfigWatcher(accessKey, configPath);
|
|
205
|
+
agentLogger.info("session", `created ${sid} (project: ${accessKey})`);
|
|
52
206
|
},
|
|
53
207
|
onsessionclosed: (sid) => {
|
|
54
208
|
transports.delete(sid);
|
|
209
|
+
sessionProject.delete(sid);
|
|
55
210
|
agentLogger.warn("session", `closed ${sid}`);
|
|
56
211
|
},
|
|
57
212
|
});
|
|
58
|
-
const server = createServer(agentLogger, {
|
|
213
|
+
const server = createServer(agentLogger, {
|
|
214
|
+
configPath,
|
|
215
|
+
taskMode,
|
|
216
|
+
tui,
|
|
217
|
+
agentId,
|
|
218
|
+
projectKey: accessKey,
|
|
219
|
+
});
|
|
59
220
|
servers.add(server);
|
|
60
221
|
transport.onclose = () => {
|
|
61
222
|
if (transport.sessionId) {
|
|
62
223
|
transports.delete(transport.sessionId);
|
|
224
|
+
sessionProject.delete(transport.sessionId);
|
|
63
225
|
}
|
|
64
226
|
servers.delete(server);
|
|
227
|
+
// Remove from project tracking
|
|
228
|
+
const projectSet = projectServers.get(accessKey);
|
|
229
|
+
if (projectSet) {
|
|
230
|
+
projectSet.delete(server);
|
|
231
|
+
if (projectSet.size === 0) {
|
|
232
|
+
projectServers.delete(accessKey);
|
|
233
|
+
stopConfigWatcher(accessKey);
|
|
234
|
+
tui?.removeProject(accessKey);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
65
237
|
};
|
|
66
238
|
await server.connect(transport);
|
|
67
239
|
return transport.handleRequest(req, { parsedBody: body });
|
|
@@ -77,29 +249,96 @@ async function handleMcp(req) {
|
|
|
77
249
|
}
|
|
78
250
|
return new Response("Method Not Allowed", { status: 405 });
|
|
79
251
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
252
|
+
// -- CLI command handlers -------------------------------------------------
|
|
253
|
+
async function handleAdd(rawPath) {
|
|
254
|
+
let resolved = resolve(rawPath);
|
|
255
|
+
const fileStat = await stat(resolved).catch(() => null);
|
|
256
|
+
if (fileStat?.isDirectory()) {
|
|
257
|
+
resolved = join(resolved, ".exocommand");
|
|
258
|
+
}
|
|
259
|
+
if (!(await configExists(resolved))) {
|
|
260
|
+
console.error(`\n ${RED}${BOLD}✗${RESET} Config file not found: ${DIM}${resolved}${RESET}\n`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
89
263
|
try {
|
|
90
|
-
|
|
264
|
+
await loadConfig(resolved);
|
|
91
265
|
}
|
|
92
266
|
catch (err) {
|
|
93
|
-
|
|
267
|
+
console.error(`\n ${RED}${BOLD}✗${RESET} Invalid config: ${err.message}\n`);
|
|
94
268
|
process.exit(1);
|
|
95
269
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
270
|
+
const accessKey = await addProject(resolved);
|
|
271
|
+
console.log(`
|
|
272
|
+
${GREEN}${BOLD}✓${RESET} Project registered
|
|
273
|
+
|
|
274
|
+
${DIM}Key${RESET} ${CYAN}${BOLD}${accessKey}${RESET}
|
|
275
|
+
${DIM}Header${RESET} ${DIM}exocommand-project: ${accessKey}${RESET}
|
|
276
|
+
${DIM}Config${RESET} ${DIM}${resolved}${RESET}
|
|
277
|
+
`);
|
|
278
|
+
}
|
|
279
|
+
async function handleLs() {
|
|
280
|
+
const registry = await listProjects();
|
|
281
|
+
const entries = Object.entries(registry);
|
|
282
|
+
if (entries.length === 0) {
|
|
283
|
+
console.log(`\n ${DIM}No projects registered.${RESET}\n`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const keyWidth = 14;
|
|
287
|
+
console.log(`\n ${BOLD}${MAGENTA}KEY${RESET}${" ".repeat(keyWidth - 3)}${BOLD}${MAGENTA}PATH${RESET}`);
|
|
288
|
+
console.log(` ${DIM}${"─".repeat(keyWidth)}${"─".repeat(40)}${RESET}`);
|
|
289
|
+
for (const [key, filePath] of entries) {
|
|
290
|
+
const exists = await configExists(filePath);
|
|
291
|
+
const pathDisplay = exists
|
|
292
|
+
? `${DIM}${filePath}${RESET}`
|
|
293
|
+
: `${DIM}${filePath} ${RED}(missing)${RESET}`;
|
|
294
|
+
console.log(` ${CYAN}${BOLD}${key}${RESET}${" ".repeat(keyWidth - key.length)}${pathDisplay}`);
|
|
295
|
+
}
|
|
296
|
+
console.log();
|
|
297
|
+
}
|
|
298
|
+
async function handleRm(target) {
|
|
299
|
+
// Detect if target is a path (contains / or . or \) vs an access key
|
|
300
|
+
const isPath = target.includes("/") || target.includes("\\") || target === ".";
|
|
301
|
+
try {
|
|
302
|
+
if (isPath) {
|
|
303
|
+
let resolved = resolve(target);
|
|
304
|
+
const fileStat = await stat(resolved).catch(() => null);
|
|
305
|
+
if (fileStat?.isDirectory()) {
|
|
306
|
+
resolved = join(resolved, ".exocommand");
|
|
307
|
+
}
|
|
308
|
+
await removeProjectByPath(resolved);
|
|
309
|
+
console.log(`\n ${GREEN}${BOLD}✓${RESET} Project removed ${DIM}(${resolved})${RESET}\n`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
await removeProject(target);
|
|
313
|
+
console.log(`\n ${GREEN}${BOLD}✓${RESET} Project ${CYAN}${BOLD}${target}${RESET} removed\n`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.error(`\n ${RED}${BOLD}✗${RESET} ${err.message}\n`);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function handleInit() {
|
|
322
|
+
const configPath = resolve(".exocommand");
|
|
323
|
+
if (await configExists(configPath)) {
|
|
324
|
+
console.error(`\n ${RED}${BOLD}✗${RESET} Config file already exists: ${DIM}${configPath}${RESET}\n`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
await createSampleConfig(configPath);
|
|
328
|
+
console.log(`\n ${GREEN}${BOLD}✓${RESET} Created ${DIM}${configPath}${RESET}\n`);
|
|
329
|
+
}
|
|
330
|
+
// -- Server startup -------------------------------------------------------
|
|
331
|
+
async function startServer() {
|
|
332
|
+
if (process.stdout.isTTY) {
|
|
333
|
+
tui = await createTui(version);
|
|
334
|
+
activeLogger = createTuiLogger(tui);
|
|
335
|
+
}
|
|
336
|
+
const PORT = parseInt(process.env.EXO_PORT || "5555", 10);
|
|
337
|
+
tui?.setPort(PORT);
|
|
338
|
+
// Resolve task mode from env
|
|
98
339
|
const envTaskMode = process.env.EXO_TASK_MODE;
|
|
99
|
-
taskMode = envTaskMode
|
|
100
|
-
|
|
101
|
-
: config.taskMode ?? false;
|
|
102
|
-
logger.info("startup", `execution mode: ${taskMode ? "task" : "streaming"}`);
|
|
340
|
+
taskMode = envTaskMode === "true" || envTaskMode === "1";
|
|
341
|
+
activeLogger.info("startup", `execution mode: ${taskMode ? "task" : "streaming"}`);
|
|
103
342
|
try {
|
|
104
343
|
const listener = getRequestListener(handleMcp, {
|
|
105
344
|
overrideGlobalObjects: false,
|
|
@@ -110,33 +349,39 @@ async function main() {
|
|
|
110
349
|
httpServer.listen(PORT, "127.0.0.1");
|
|
111
350
|
}
|
|
112
351
|
catch (err) {
|
|
113
|
-
|
|
352
|
+
activeLogger.error("startup", `Failed to start server on port ${PORT}: ${err.message}`);
|
|
353
|
+
tui?.destroy();
|
|
114
354
|
process.exit(1);
|
|
115
355
|
}
|
|
116
|
-
|
|
117
|
-
// Watch
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
356
|
+
activeLogger.info("server", `listening on http://127.0.0.1:${PORT}/mcp`);
|
|
357
|
+
// Watch the registry file for changes from other CLI processes
|
|
358
|
+
watchRegistry();
|
|
359
|
+
// Start watching all currently registered project configs
|
|
360
|
+
await syncConfigWatchers();
|
|
361
|
+
}
|
|
362
|
+
// -- Main entry point -----------------------------------------------------
|
|
363
|
+
async function main() {
|
|
364
|
+
const action = parseArgs(process.argv);
|
|
365
|
+
switch (action.kind) {
|
|
366
|
+
case "add":
|
|
367
|
+
await handleAdd(action.path);
|
|
121
368
|
return;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}, 200);
|
|
136
|
-
});
|
|
137
|
-
logger.info("watcher", `watching ${CONFIG_PATH} for changes`);
|
|
369
|
+
case "init":
|
|
370
|
+
await handleInit();
|
|
371
|
+
return;
|
|
372
|
+
case "ls":
|
|
373
|
+
await handleLs();
|
|
374
|
+
return;
|
|
375
|
+
case "rm":
|
|
376
|
+
await handleRm(action.target);
|
|
377
|
+
return;
|
|
378
|
+
case "serve":
|
|
379
|
+
await startServer();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
138
382
|
}
|
|
139
383
|
main().catch((err) => {
|
|
384
|
+
tui?.destroy();
|
|
140
385
|
logger.error("fatal", `Unexpected error: ${err.message}`);
|
|
141
386
|
process.exit(1);
|
|
142
387
|
});
|
package/dist/logger.js
CHANGED
|
@@ -36,23 +36,36 @@ function createLogger(agentId) {
|
|
|
36
36
|
}
|
|
37
37
|
export const logger = {
|
|
38
38
|
...createLogger(),
|
|
39
|
-
banner(version) {
|
|
40
|
-
const v = `v${version}`;
|
|
41
|
-
// 32-char wide interior: " ● ○ ○" (7 chars) + padding + version + " " (2 trailing)
|
|
42
|
-
const dotsLeft = " ● ○ ○";
|
|
43
|
-
const padLen = 32 - dotsLeft.length - v.length - 2;
|
|
44
|
-
const pad = " ".repeat(Math.max(padLen, 1));
|
|
45
|
-
const lines = [
|
|
46
|
-
`${DIM} ╭────────────────────────────────╮${RESET}`,
|
|
47
|
-
`${DIM} │${RESET} ${RED}●${RESET} ${DIM}○ ○${pad}${v}${RESET} ${DIM}│${RESET}`,
|
|
48
|
-
`${DIM} │${RESET} ${GREEN}>${RESET} ${BOLD}${CYAN}EXOCOMMAND${RESET}${GREEN}_${RESET} ${DIM}├══════╗${RESET}`,
|
|
49
|
-
`${DIM} ╰────────────────────────────────╯ ┌════╝${RESET}`,
|
|
50
|
-
`${DIM} └══╗${RESET}`,
|
|
51
|
-
`${DIM} ╹${RESET}`,
|
|
52
|
-
];
|
|
53
|
-
console.log(lines.join("\n"));
|
|
54
|
-
},
|
|
55
39
|
withAgent(id) {
|
|
56
40
|
return createLogger(id);
|
|
57
41
|
},
|
|
58
42
|
};
|
|
43
|
+
// TUI-aware logger that delegates to TuiManager instead of console
|
|
44
|
+
export function createTuiLogger(tui) {
|
|
45
|
+
function makeLogger(agentId) {
|
|
46
|
+
return {
|
|
47
|
+
info(event, message) {
|
|
48
|
+
const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
|
|
49
|
+
tui.logMessage("info", event, msg);
|
|
50
|
+
},
|
|
51
|
+
success(event, message) {
|
|
52
|
+
const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
|
|
53
|
+
tui.logMessage("success", event, msg);
|
|
54
|
+
},
|
|
55
|
+
warn(event, message) {
|
|
56
|
+
const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
|
|
57
|
+
tui.logMessage("warn", event, msg);
|
|
58
|
+
},
|
|
59
|
+
error(event, message) {
|
|
60
|
+
const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
|
|
61
|
+
tui.logMessage("error", event, msg);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...makeLogger(),
|
|
67
|
+
withAgent(id) {
|
|
68
|
+
return makeLogger(id);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
const REGISTRY_DIR = join(homedir(), ".exocommand");
|
|
6
|
+
const REGISTRY_PATH = join(REGISTRY_DIR, "exocommand.db.json");
|
|
7
|
+
export function getRegistryPath() {
|
|
8
|
+
return REGISTRY_PATH;
|
|
9
|
+
}
|
|
10
|
+
export async function loadRegistry() {
|
|
11
|
+
try {
|
|
12
|
+
await access(REGISTRY_PATH);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
const content = await readFile(REGISTRY_PATH, "utf-8");
|
|
18
|
+
const parsed = JSON.parse(content);
|
|
19
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
20
|
+
throw new Error("Invalid registry format: expected a JSON object");
|
|
21
|
+
}
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
export async function saveRegistry(registry) {
|
|
25
|
+
await mkdir(REGISTRY_DIR, { recursive: true });
|
|
26
|
+
await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
27
|
+
}
|
|
28
|
+
export function generateAccessKey() {
|
|
29
|
+
return createHash("sha256").update(randomUUID()).digest("hex").slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
export async function addProject(filePath) {
|
|
32
|
+
const absolute = resolve(filePath);
|
|
33
|
+
await access(absolute);
|
|
34
|
+
const registry = await loadRegistry();
|
|
35
|
+
// Check for duplicate path — return existing key
|
|
36
|
+
for (const [key, existingPath] of Object.entries(registry)) {
|
|
37
|
+
if (existingPath === absolute) {
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Generate a unique key (handle unlikely collisions)
|
|
42
|
+
let accessKey = generateAccessKey();
|
|
43
|
+
while (registry[accessKey] !== undefined) {
|
|
44
|
+
accessKey = generateAccessKey();
|
|
45
|
+
}
|
|
46
|
+
registry[accessKey] = absolute;
|
|
47
|
+
await saveRegistry(registry);
|
|
48
|
+
return accessKey;
|
|
49
|
+
}
|
|
50
|
+
export async function removeProject(accessKey) {
|
|
51
|
+
const registry = await loadRegistry();
|
|
52
|
+
if (registry[accessKey] === undefined) {
|
|
53
|
+
throw new Error(`Project "${accessKey}" not found in registry`);
|
|
54
|
+
}
|
|
55
|
+
delete registry[accessKey];
|
|
56
|
+
await saveRegistry(registry);
|
|
57
|
+
}
|
|
58
|
+
export async function removeProjectByPath(filePath) {
|
|
59
|
+
const absolute = resolve(filePath);
|
|
60
|
+
const registry = await loadRegistry();
|
|
61
|
+
for (const [key, existingPath] of Object.entries(registry)) {
|
|
62
|
+
if (existingPath === absolute) {
|
|
63
|
+
delete registry[key];
|
|
64
|
+
await saveRegistry(registry);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`No project registered for path: ${absolute}`);
|
|
69
|
+
}
|
|
70
|
+
export async function listProjects() {
|
|
71
|
+
return loadRegistry();
|
|
72
|
+
}
|
|
73
|
+
export async function resolveProject(accessKey) {
|
|
74
|
+
const registry = await loadRegistry();
|
|
75
|
+
const path = registry[accessKey];
|
|
76
|
+
if (path === undefined) {
|
|
77
|
+
throw new Error(`Project "${accessKey}" not found in registry`);
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { loadRegistry, saveRegistry, generateAccessKey, addProject, removeProject, removeProjectByPath, listProjects, resolveProject, } from "./registry";
|
|
6
|
+
// Override the registry path for testing by monkey-patching the module internals.
|
|
7
|
+
// Instead, we test using temp .exocommand files and the real registry functions
|
|
8
|
+
// operating on the actual registry path. To isolate tests, we save/restore.
|
|
9
|
+
let tmpDir;
|
|
10
|
+
let sampleConfigPath;
|
|
11
|
+
let sampleConfigPath2;
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
tmpDir = await mkdtemp(join(tmpdir(), "exocommand-registry-test-"));
|
|
14
|
+
sampleConfigPath = join(tmpDir, ".exocommand");
|
|
15
|
+
sampleConfigPath2 = join(tmpDir, ".exocommand2");
|
|
16
|
+
await writeFile(sampleConfigPath, 'hello:\n description: "hi"\n command: "echo hi"\n');
|
|
17
|
+
await writeFile(sampleConfigPath2, 'build:\n description: "build"\n command: "bun run build"\n');
|
|
18
|
+
});
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
describe("generateAccessKey", () => {
|
|
23
|
+
test("returns a 12-char hex string", () => {
|
|
24
|
+
const key = generateAccessKey();
|
|
25
|
+
expect(key).toHaveLength(12);
|
|
26
|
+
expect(key).toMatch(/^[0-9a-f]{12}$/);
|
|
27
|
+
});
|
|
28
|
+
test("returns unique keys on successive calls", () => {
|
|
29
|
+
const keys = new Set(Array.from({ length: 100 }, () => generateAccessKey()));
|
|
30
|
+
expect(keys.size).toBe(100);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("registry CRUD", () => {
|
|
34
|
+
test("loadRegistry returns empty object when file does not exist", async () => {
|
|
35
|
+
// The real registry file may or may not exist, so we test the function's behavior
|
|
36
|
+
// by checking it returns a valid object
|
|
37
|
+
const registry = await loadRegistry();
|
|
38
|
+
expect(typeof registry).toBe("object");
|
|
39
|
+
expect(registry).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
test("saveRegistry creates directory and writes file", async () => {
|
|
42
|
+
const registry = await loadRegistry();
|
|
43
|
+
// Saving the same registry should not throw
|
|
44
|
+
await saveRegistry(registry);
|
|
45
|
+
const reloaded = await loadRegistry();
|
|
46
|
+
expect(reloaded).toEqual(registry);
|
|
47
|
+
});
|
|
48
|
+
test("addProject registers a new project and returns 12-char key", async () => {
|
|
49
|
+
const key = await addProject(sampleConfigPath);
|
|
50
|
+
expect(key).toHaveLength(12);
|
|
51
|
+
expect(key).toMatch(/^[0-9a-f]{12}$/);
|
|
52
|
+
// Verify it's in the registry
|
|
53
|
+
const registry = await loadRegistry();
|
|
54
|
+
expect(registry[key]).toBe(sampleConfigPath);
|
|
55
|
+
});
|
|
56
|
+
test("addProject returns existing key for duplicate path", async () => {
|
|
57
|
+
const key1 = await addProject(sampleConfigPath);
|
|
58
|
+
const key2 = await addProject(sampleConfigPath);
|
|
59
|
+
expect(key1).toBe(key2);
|
|
60
|
+
});
|
|
61
|
+
test("addProject throws for non-existent file", async () => {
|
|
62
|
+
await expect(addProject(join(tmpDir, "nonexistent"))).rejects.toThrow();
|
|
63
|
+
});
|
|
64
|
+
test("resolveProject returns path for valid key", async () => {
|
|
65
|
+
const key = await addProject(sampleConfigPath);
|
|
66
|
+
const resolved = await resolveProject(key);
|
|
67
|
+
expect(resolved).toBe(sampleConfigPath);
|
|
68
|
+
});
|
|
69
|
+
test("resolveProject throws for unknown key", async () => {
|
|
70
|
+
await expect(resolveProject("zzzzzzzz")).rejects.toThrow('Project "zzzzzzzz" not found in registry');
|
|
71
|
+
});
|
|
72
|
+
test("listProjects returns all registered projects", async () => {
|
|
73
|
+
const key1 = await addProject(sampleConfigPath);
|
|
74
|
+
const key2 = await addProject(sampleConfigPath2);
|
|
75
|
+
const projects = await listProjects();
|
|
76
|
+
expect(projects[key1]).toBe(sampleConfigPath);
|
|
77
|
+
expect(projects[key2]).toBe(sampleConfigPath2);
|
|
78
|
+
});
|
|
79
|
+
test("removeProject deletes the key", async () => {
|
|
80
|
+
const key = await addProject(sampleConfigPath2);
|
|
81
|
+
await removeProject(key);
|
|
82
|
+
const registry = await loadRegistry();
|
|
83
|
+
expect(registry[key]).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
test("removeProject throws for unknown key", async () => {
|
|
86
|
+
await expect(removeProject("zzzzzzzzzzzz")).rejects.toThrow('Project "zzzzzzzzzzzz" not found in registry');
|
|
87
|
+
});
|
|
88
|
+
test("removeProjectByPath removes by config file path", async () => {
|
|
89
|
+
const key = await addProject(sampleConfigPath2);
|
|
90
|
+
await removeProjectByPath(sampleConfigPath2);
|
|
91
|
+
const registry = await loadRegistry();
|
|
92
|
+
expect(registry[key]).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
test("removeProjectByPath throws for unknown path", async () => {
|
|
95
|
+
await expect(removeProjectByPath("/nonexistent/.exocommand")).rejects.toThrow("No project registered for path: /nonexistent/.exocommand");
|
|
96
|
+
});
|
|
97
|
+
test("full lifecycle: add, list, resolve, remove", async () => {
|
|
98
|
+
const key = await addProject(sampleConfigPath);
|
|
99
|
+
expect(key).toHaveLength(12);
|
|
100
|
+
const projects = await listProjects();
|
|
101
|
+
expect(projects[key]).toBe(sampleConfigPath);
|
|
102
|
+
const resolved = await resolveProject(key);
|
|
103
|
+
expect(resolved).toBe(sampleConfigPath);
|
|
104
|
+
await removeProject(key);
|
|
105
|
+
await expect(resolveProject(key)).rejects.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|