@townco/cli 0.1.73 → 0.1.75

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,8 @@
1
+ export interface BatchCommandOptions {
2
+ name: string;
3
+ queries?: string[];
4
+ file?: string;
5
+ concurrency?: number;
6
+ port?: number;
7
+ }
8
+ export declare function batchCommand(options: BatchCommandOptions): Promise<void>;
@@ -0,0 +1,270 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { isInsideTownProject } from "@townco/agent/storage";
5
+ import { createLogger } from "@townco/core";
6
+ import { AcpClient } from "@townco/ui";
7
+ import { findAvailablePort } from "../lib/port-utils.js";
8
+ // Helper to wait for server to be ready
9
+ async function waitForServer(url, maxRetries = 30) {
10
+ for (let i = 0; i < maxRetries; i++) {
11
+ try {
12
+ const response = await fetch(url);
13
+ if (response.ok) {
14
+ return true;
15
+ }
16
+ }
17
+ catch {
18
+ // Server not ready yet
19
+ }
20
+ await new Promise((resolve) => setTimeout(resolve, 500));
21
+ }
22
+ return false;
23
+ }
24
+ // Run a single query
25
+ async function runQuery(agentPort, query, queryNum, total, logger) {
26
+ logger.info("Starting query", { queryNum, total, query });
27
+ console.log(`[${queryNum}/${total}] Starting: "${query}"`);
28
+ const client = new AcpClient({
29
+ type: "http",
30
+ options: {
31
+ baseUrl: `http://localhost:${agentPort}`,
32
+ },
33
+ });
34
+ try {
35
+ // Connect and create a new session
36
+ await client.connect();
37
+ const sessionId = await client.startSession();
38
+ if (!sessionId) {
39
+ throw new Error("Failed to create session");
40
+ }
41
+ logger.info("Session created", { sessionId });
42
+ console.log(`[${queryNum}/${total}] Session: ${sessionId}`);
43
+ // Send the query
44
+ await client.sendMessage(query, sessionId);
45
+ // Collect the response (but don't print it - just wait for completion)
46
+ for await (const chunk of client.receiveMessages()) {
47
+ if (chunk.type === "content" && chunk.isComplete) {
48
+ break;
49
+ }
50
+ }
51
+ console.log(`[${queryNum}/${total}] ✓ Completed`);
52
+ logger.info("Query completed", { queryNum });
53
+ await client.disconnect();
54
+ }
55
+ catch (error) {
56
+ const errorMsg = error instanceof Error ? error.message : String(error);
57
+ console.error(`[${queryNum}/${total}] ✗ Error: ${errorMsg}`);
58
+ logger.error("Query failed", { queryNum, error: errorMsg });
59
+ await client.disconnect();
60
+ throw error;
61
+ }
62
+ }
63
+ // Run queries in batches with concurrency limit
64
+ async function runQueriesWithConcurrency(agentPort, queries, concurrency, logger) {
65
+ const total = queries.length;
66
+ let completed = 0;
67
+ let currentIndex = 0;
68
+ // Worker function
69
+ const worker = async () => {
70
+ while (currentIndex < queries.length) {
71
+ const index = currentIndex++;
72
+ const query = queries[index];
73
+ if (!query)
74
+ continue; // Skip if undefined (shouldn't happen)
75
+ const queryNum = index + 1;
76
+ try {
77
+ await runQuery(agentPort, query, queryNum, total, logger);
78
+ completed++;
79
+ }
80
+ catch (error) {
81
+ console.error(`Query ${queryNum} failed:`, error);
82
+ completed++;
83
+ }
84
+ }
85
+ };
86
+ // Start workers
87
+ const workers = Array.from({ length: concurrency }, () => worker());
88
+ await Promise.all(workers);
89
+ console.log(`\n✓ All ${completed}/${total} queries completed\n`);
90
+ logger.info("All queries completed", { total, completed });
91
+ }
92
+ export async function batchCommand(options) {
93
+ const { name, queries: inlineQueries = [], file, concurrency = 10, port = 3100, } = options;
94
+ const logger = createLogger("batch-command", "debug");
95
+ // Check if we're inside a Town project
96
+ const projectRoot = await isInsideTownProject();
97
+ if (projectRoot === null) {
98
+ console.error("Error: Not inside a Town project.");
99
+ console.log("\nPlease run this command inside a project directory, or run:\n" +
100
+ " town create --init <path>\n" +
101
+ "to create a project.");
102
+ process.exit(1);
103
+ }
104
+ // Load queries from file or use inline queries
105
+ let queries = [];
106
+ if (file !== undefined) {
107
+ const filePath = file; // Type narrowing for TypeScript
108
+ try {
109
+ const content = await readFile(filePath, "utf-8");
110
+ // Split by newlines, trim, and filter empty lines
111
+ queries = content
112
+ .split("\n")
113
+ .map((line) => line.trim())
114
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
115
+ if (queries.length === 0) {
116
+ console.error(`Error: No queries found in file: ${filePath}`);
117
+ process.exit(1);
118
+ }
119
+ console.log(`Loaded ${queries.length} queries from ${filePath}\n`);
120
+ }
121
+ catch (error) {
122
+ console.error(`Error reading file: ${filePath}`);
123
+ console.error(error instanceof Error ? error.message : String(error));
124
+ process.exit(1);
125
+ }
126
+ }
127
+ else if (inlineQueries.length > 0) {
128
+ queries = inlineQueries;
129
+ }
130
+ else {
131
+ console.error("Error: No queries provided.");
132
+ console.log("\nUsage:");
133
+ console.log(' town batch <agent> "query1" "query2" ...');
134
+ console.log(" town batch <agent> --file queries.txt");
135
+ process.exit(1);
136
+ }
137
+ console.log(`=== Batch Query Execution ===`);
138
+ console.log(`Agent: ${name}`);
139
+ console.log(`Queries: ${queries.length}`);
140
+ console.log(`Concurrency: ${concurrency}\n`);
141
+ const agentPath = join(projectRoot, "agents", name);
142
+ const binPath = join(agentPath, "bin.ts");
143
+ const tracesDbPath = join(agentPath, ".traces.db");
144
+ // Check if agent exists
145
+ try {
146
+ const { stat } = await import("node:fs/promises");
147
+ await stat(agentPath);
148
+ }
149
+ catch {
150
+ console.error(`Error: Agent "${name}" not found.`);
151
+ console.log('\nCreate an agent with "town create" or list agents with "town list"');
152
+ process.exit(1);
153
+ }
154
+ // Load agent display name for debugger
155
+ let agentDisplayName = name;
156
+ try {
157
+ const agentIndexPath = join(agentPath, "index.ts");
158
+ const content = await readFile(agentIndexPath, "utf-8");
159
+ const displayNameMatch = content.match(/displayName:\s*["']([^"']+)["']/);
160
+ if (displayNameMatch?.[1]) {
161
+ agentDisplayName = displayNameMatch[1];
162
+ }
163
+ }
164
+ catch {
165
+ // Fallback to directory name
166
+ }
167
+ let debuggerProcess = null;
168
+ let agentProcess = null;
169
+ // Cleanup function
170
+ const cleanup = () => {
171
+ logger.info("Cleaning up processes");
172
+ if (agentProcess?.pid) {
173
+ try {
174
+ process.kill(-agentProcess.pid, "SIGINT");
175
+ }
176
+ catch (e) {
177
+ // Process may already be dead
178
+ }
179
+ }
180
+ if (debuggerProcess?.pid) {
181
+ try {
182
+ process.kill(-debuggerProcess.pid, "SIGKILL");
183
+ }
184
+ catch (e) {
185
+ // Process may already be dead
186
+ }
187
+ }
188
+ };
189
+ // Register cleanup handlers
190
+ process.on("SIGINT", () => {
191
+ cleanup();
192
+ process.exit(0);
193
+ });
194
+ process.on("SIGTERM", () => {
195
+ cleanup();
196
+ process.exit(0);
197
+ });
198
+ try {
199
+ // 1. Start debugger process
200
+ console.log("Starting debugger...");
201
+ const debuggerPkgPath = require.resolve("@townco/debugger/package.json");
202
+ const debuggerDir = dirname(debuggerPkgPath);
203
+ debuggerProcess = spawn("bun", ["src/index.ts"], {
204
+ cwd: debuggerDir,
205
+ stdio: "ignore",
206
+ detached: true,
207
+ env: {
208
+ ...process.env,
209
+ DB_PATH: tracesDbPath,
210
+ AGENT_NAME: agentDisplayName,
211
+ },
212
+ });
213
+ // Wait for debugger to be ready
214
+ const debuggerReady = await waitForServer("http://localhost:4318/health");
215
+ if (!debuggerReady) {
216
+ throw new Error("Debugger failed to start");
217
+ }
218
+ console.log("✓ Debugger ready (UI: http://localhost:4000)\n");
219
+ logger.info("Debugger started");
220
+ // 2. Start agent HTTP server
221
+ console.log("Starting agent HTTP server...");
222
+ // Find available port
223
+ const availablePort = await findAvailablePort(port);
224
+ if (availablePort !== port) {
225
+ console.log(`Port ${port} in use, using port ${availablePort} instead`);
226
+ }
227
+ agentProcess = spawn("bun", [binPath, "http"], {
228
+ cwd: agentPath,
229
+ stdio: "ignore",
230
+ detached: true,
231
+ env: {
232
+ ...process.env,
233
+ PORT: String(availablePort),
234
+ ENABLE_TELEMETRY: "true",
235
+ },
236
+ });
237
+ // Wait for agent to be ready
238
+ const agentReady = await waitForServer(`http://localhost:${availablePort}/health`);
239
+ if (!agentReady) {
240
+ throw new Error("Agent failed to start");
241
+ }
242
+ console.log(`✓ Agent ready (port ${availablePort})\n`);
243
+ logger.info("Agent started", { port: availablePort });
244
+ // 3. Run queries in parallel
245
+ console.log(`Running ${queries.length} queries...\n`);
246
+ await runQueriesWithConcurrency(availablePort, queries, concurrency, logger);
247
+ // 4. Give telemetry time to flush
248
+ console.log("Flushing telemetry data...");
249
+ await new Promise((resolve) => setTimeout(resolve, 3000));
250
+ console.log("========================================");
251
+ console.log(`✓ All ${queries.length} queries completed!`);
252
+ console.log("");
253
+ console.log("Results saved to:");
254
+ console.log(` Sessions: agents/${name}/.sessions/`);
255
+ console.log(` Traces: agents/${name}/.traces.db`);
256
+ console.log("");
257
+ console.log("View traces at: http://localhost:4000");
258
+ console.log("========================================");
259
+ }
260
+ catch (error) {
261
+ console.error("\nError:", error instanceof Error ? error.message : String(error));
262
+ logger.error("Batch command failed", {
263
+ error: error instanceof Error ? error.message : String(error),
264
+ });
265
+ process.exit(1);
266
+ }
267
+ finally {
268
+ cleanup();
269
+ }
270
+ }
@@ -1,4 +1,4 @@
1
- import { clearAuthCredentials, getAuthFilePath, getShedUrl, loadAuthCredentials, saveAuthCredentials, } from "@townco/core/auth";
1
+ import { clearAuthCredentials, getShedUrl, loadAuthCredentialsFromDisk, saveAuthCredentials, } from "@townco/core/auth";
2
2
  import inquirer from "inquirer";
3
3
  // ============================================================================
4
4
  // Command Implementation
@@ -7,7 +7,7 @@ export async function loginCommand() {
7
7
  const shedUrl = getShedUrl();
8
8
  console.log("Town Login\n");
9
9
  // Check if already logged in
10
- const existingCredentials = loadAuthCredentials();
10
+ const existingCredentials = loadAuthCredentialsFromDisk();
11
11
  if (existingCredentials) {
12
12
  console.log(`Currently logged in as: ${existingCredentials.user.email}`);
13
13
  console.log(`Shed URL: ${existingCredentials.shed_url}\n`);
@@ -93,9 +93,9 @@ export async function loginCommand() {
93
93
  user: data.user,
94
94
  shed_url: shedUrl,
95
95
  };
96
- saveAuthCredentials(credentials);
96
+ const authFilePath = saveAuthCredentials(credentials);
97
97
  console.log(`\nLogged in as ${data.user.email}`);
98
- console.log(`Credentials saved to ${getAuthFilePath()}`);
98
+ console.log(`Credentials saved to ${authFilePath}`);
99
99
  }
100
100
  catch (error) {
101
101
  if (error instanceof TypeError && error.message.includes("fetch")) {
@@ -2,6 +2,8 @@ export interface RunCommandOptions {
2
2
  name: string;
3
3
  http?: boolean;
4
4
  gui?: boolean;
5
+ cli?: boolean;
6
+ prompt?: string;
5
7
  port?: number;
6
8
  noSession?: boolean;
7
9
  }
@@ -146,8 +146,235 @@ async function loadEnvVars(projectRoot, logger) {
146
146
  }
147
147
  return envVars;
148
148
  }
149
+ // CLI mode runner - outputs directly to stdout without React UI
150
+ async function runCliMode(options) {
151
+ const { binPath, workingDir, prompt, configEnvVars, noSession, waitForDebugger = false, } = options;
152
+ // Wait for debugger to be ready BEFORE starting agent
153
+ if (waitForDebugger) {
154
+ let debuggerReady = false;
155
+ let retries = 30; // 3 seconds
156
+ while (!debuggerReady && retries > 0) {
157
+ try {
158
+ const response = await fetch(`http://localhost:4318/v1/traces`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({}),
162
+ });
163
+ // Even a 405 or 400 means the server is up and responding
164
+ debuggerReady = true;
165
+ break;
166
+ }
167
+ catch (e) {
168
+ // Server not ready yet
169
+ }
170
+ await new Promise((resolve) => setTimeout(resolve, 100));
171
+ retries--;
172
+ }
173
+ if (!debuggerReady) {
174
+ process.stderr.write("Warning: Debugger did not start in time, traces may not be collected\n");
175
+ }
176
+ }
177
+ // Start HTTP server for the agent (silently in CLI mode)
178
+ // Find available port
179
+ const { findAvailablePort } = await import("../lib/port-utils.js");
180
+ const agentPort = await findAvailablePort(3100);
181
+ // Spawn agent process with HTTP mode
182
+ const agentProcess = spawn("bun", [binPath, "http"], {
183
+ cwd: workingDir,
184
+ stdio: ["ignore", "inherit", "inherit"], // Show agent output for debugging
185
+ detached: true,
186
+ env: {
187
+ ...process.env,
188
+ ...configEnvVars,
189
+ PORT: String(agentPort),
190
+ ENABLE_TELEMETRY: "true",
191
+ ...(noSession ? { TOWN_NO_SESSION: "true" } : {}),
192
+ },
193
+ });
194
+ // Log any agent errors
195
+ agentProcess.on("error", (err) => {
196
+ process.stderr.write(`Agent process error: ${err.message}\n`);
197
+ });
198
+ agentProcess.on("exit", () => {
199
+ // Agent exit is expected, no need to log in CLI mode
200
+ });
201
+ // Wait for server to be ready
202
+ let serverReady = false;
203
+ let retries = 30; // 15 seconds
204
+ while (!serverReady && retries > 0) {
205
+ try {
206
+ const response = await fetch(`http://localhost:${agentPort}/health`);
207
+ if (response.ok) {
208
+ serverReady = true;
209
+ break;
210
+ }
211
+ }
212
+ catch {
213
+ // Server not ready yet
214
+ }
215
+ await new Promise((resolve) => setTimeout(resolve, 500));
216
+ retries--;
217
+ }
218
+ if (!serverReady) {
219
+ process.stderr.write("Agent failed to start\n");
220
+ agentProcess.kill();
221
+ process.exit(1);
222
+ }
223
+ // Create ACP client with HTTP transport
224
+ const client = new AcpClient({
225
+ type: "http",
226
+ options: {
227
+ baseUrl: `http://localhost:${agentPort}`,
228
+ },
229
+ });
230
+ // Connect the client
231
+ await client.connect();
232
+ try {
233
+ // Start session
234
+ const sessionId = await client.startSession();
235
+ if (!sessionId) {
236
+ process.stderr.write("Failed to create session\n");
237
+ process.exit(1);
238
+ }
239
+ // Echo the user prompt
240
+ console.log("\n> User:", prompt);
241
+ console.log();
242
+ // Send message (HTTP transport works properly)
243
+ await client.sendMessage(prompt, sessionId);
244
+ // Track tool calls and message state
245
+ const toolCalls = new Map();
246
+ let currentAssistantMessage = "";
247
+ let isStreamingText = false;
248
+ // Receive and render messages
249
+ for await (const chunk of client.receiveMessages()) {
250
+ if (chunk.type === "content") {
251
+ // Content chunk - text streaming
252
+ if (chunk.contentDelta?.type === "text") {
253
+ // Stream assistant text response
254
+ if (!isStreamingText) {
255
+ process.stdout.write("⏺ Assistant: ");
256
+ isStreamingText = true;
257
+ }
258
+ process.stdout.write(chunk.contentDelta.text);
259
+ currentAssistantMessage += chunk.contentDelta.text;
260
+ }
261
+ // Check if complete after processing content
262
+ if (chunk.isComplete) {
263
+ if (isStreamingText) {
264
+ console.log("\n");
265
+ isStreamingText = false;
266
+ }
267
+ break;
268
+ }
269
+ }
270
+ else if (chunk.type === "tool_call") {
271
+ // New tool call
272
+ if (isStreamingText) {
273
+ console.log("\n");
274
+ isStreamingText = false;
275
+ }
276
+ const toolCall = chunk.toolCall;
277
+ toolCalls.set(toolCall.id, {
278
+ id: toolCall.id,
279
+ name: toolCall.title || toolCall.name,
280
+ prettyName: toolCall.prettyName,
281
+ status: toolCall.status || "pending",
282
+ input: toolCall.rawInput,
283
+ });
284
+ const prettyName = toolCall.prettyName || toolCall.title || toolCall.name;
285
+ console.log(`\n🔧 Tool: ${prettyName}`);
286
+ console.log(` Status: ${toolCall.status || "pending"}`);
287
+ if (toolCall.rawInput && Object.keys(toolCall.rawInput).length > 0) {
288
+ console.log(` Input: ${JSON.stringify(toolCall.rawInput, null, 2).split("\n").join("\n ")}`);
289
+ }
290
+ }
291
+ else if (chunk.type === "tool_call_update") {
292
+ // Update existing tool call
293
+ const toolCallUpdate = chunk.toolCallUpdate;
294
+ const toolCall = toolCalls.get(toolCallUpdate.id);
295
+ if (toolCall) {
296
+ if (toolCallUpdate.status) {
297
+ toolCall.status = toolCallUpdate.status;
298
+ }
299
+ if (toolCallUpdate.rawInput) {
300
+ toolCall.input = toolCallUpdate.rawInput;
301
+ }
302
+ // Print status update
303
+ const prettyName = toolCall.prettyName || toolCall.name;
304
+ if (toolCallUpdate.status) {
305
+ console.log(`\n🔧 Tool: ${prettyName}`);
306
+ console.log(` Status: ${toolCallUpdate.status}`);
307
+ }
308
+ // Handle tool output
309
+ if (toolCallUpdate.content && Array.isArray(toolCallUpdate.content)) {
310
+ let outputText = "";
311
+ for (const block of toolCallUpdate.content) {
312
+ if (block.type === "content" && block.content.type === "text") {
313
+ outputText += block.content.text;
314
+ }
315
+ }
316
+ if (outputText) {
317
+ toolCall.output = outputText;
318
+ // Truncate very long output
319
+ const maxOutputLength = 500;
320
+ if (outputText.length > maxOutputLength) {
321
+ const truncated = outputText.substring(0, maxOutputLength);
322
+ console.log(` Output: ${truncated}...`);
323
+ console.log(` (${outputText.length} characters, truncated)`);
324
+ }
325
+ else {
326
+ console.log(` Output: ${outputText}`);
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ // Final newline
334
+ if (isStreamingText) {
335
+ console.log("\n");
336
+ }
337
+ // Disconnect client and cleanup
338
+ await client.disconnect();
339
+ // Gracefully shutdown agent process with SIGINT (triggers telemetry flush)
340
+ if (agentProcess.pid) {
341
+ try {
342
+ process.kill(-agentProcess.pid, "SIGINT");
343
+ // Wait for agent to fully exit (including telemetry flush)
344
+ await new Promise((resolve) => {
345
+ agentProcess.on("exit", () => resolve());
346
+ // Fallback timeout in case agent doesn't exit
347
+ setTimeout(() => resolve(), 3000);
348
+ });
349
+ }
350
+ catch (e) {
351
+ // Process may already be dead
352
+ }
353
+ }
354
+ }
355
+ catch (error) {
356
+ console.error("\nError:", error instanceof Error ? error.message : String(error));
357
+ console.error("Stack:", error instanceof Error ? error.stack : "");
358
+ try {
359
+ await client.disconnect();
360
+ }
361
+ catch (disconnectError) {
362
+ console.error("Failed to disconnect:", disconnectError);
363
+ }
364
+ // Kill agent process on error
365
+ if (agentProcess.pid) {
366
+ try {
367
+ process.kill(-agentProcess.pid, "SIGKILL");
368
+ }
369
+ catch (e) {
370
+ // Process may already be dead
371
+ }
372
+ }
373
+ process.exit(1);
374
+ }
375
+ }
149
376
  export async function runCommand(options) {
150
- const { name, http = false, gui = false, port = 3100, noSession = false, } = options;
377
+ const { name, http = false, gui = false, cli = false, prompt, port = 3100, noSession = false, } = options;
151
378
  // Check if we're inside a Town project
152
379
  const projectRoot = await isInsideTownProject();
153
380
  if (projectRoot === null) {
@@ -161,43 +388,51 @@ export async function runCommand(options) {
161
388
  const configEnvVars = await loadEnvVars(projectRoot);
162
389
  // Resolve agent path to load agent definition
163
390
  const agentPath = join(projectRoot, "agents", name);
164
- // Load agent definition to get displayName by parsing the file
391
+ // Load agent definition to get displayName and detect MCPs by parsing the file
165
392
  let agentDisplayName = name; // Fallback to agent directory name
393
+ let usesBibliotechaMcp = false;
166
394
  try {
167
395
  const agentIndexPath = join(agentPath, "index.ts");
168
396
  const content = await readFile(agentIndexPath, "utf-8");
169
397
  // Match displayName in the agent definition object
170
398
  // Looking for patterns like: displayName: "Researcher" or displayName: 'Researcher'
171
- const match = content.match(/displayName:\s*["']([^"']+)["']/);
172
- if (match?.[1]) {
173
- agentDisplayName = match[1];
399
+ const displayNameMatch = content.match(/displayName:\s*["']([^"']+)["']/);
400
+ if (displayNameMatch?.[1]) {
401
+ agentDisplayName = displayNameMatch[1];
174
402
  }
403
+ // Detect if agent uses bibliotecha MCP
404
+ // Looking for patterns like: name: "bibliotecha" or name: 'bibliotecha'
405
+ usesBibliotechaMcp = /name:\s*["']bibliotecha["']/.test(content);
175
406
  }
176
407
  catch (error) {
177
408
  // If we can't read the agent definition, just use the directory name
178
409
  // Silently fail - the directory name is a reasonable fallback
179
410
  }
180
411
  // Start the debugger server as subprocess (OTLP collector + UI)
412
+ let debuggerProcess = null;
413
+ let cleanupDebugger = null;
181
414
  const debuggerPkgPath = require.resolve("@townco/debugger/package.json");
182
415
  const debuggerDir = dirname(debuggerPkgPath);
183
- const debuggerProcess = spawn("bun", ["src/index.ts"], {
416
+ debuggerProcess = spawn("bun", ["src/index.ts"], {
184
417
  cwd: debuggerDir,
185
- stdio: "inherit",
418
+ stdio: cli ? "ignore" : "inherit", // Silent in CLI mode
186
419
  detached: true,
187
420
  env: {
188
421
  ...process.env,
189
- DB_PATH: join(projectRoot, ".traces.db"),
422
+ DB_PATH: join(projectRoot, "agents", name, ".traces.db"),
190
423
  AGENT_NAME: agentDisplayName,
191
424
  },
192
425
  });
193
- console.log(`Debugger UI: http://localhost:4000`);
426
+ if (!cli) {
427
+ console.log(`Debugger UI: http://localhost:4000`);
428
+ }
194
429
  // Cleanup debugger process on exit
195
430
  let isDebuggerCleaningUp = false;
196
- const cleanupDebugger = () => {
431
+ cleanupDebugger = () => {
197
432
  if (isDebuggerCleaningUp)
198
433
  return;
199
434
  isDebuggerCleaningUp = true;
200
- if (debuggerProcess.pid) {
435
+ if (debuggerProcess?.pid) {
201
436
  try {
202
437
  process.kill(-debuggerProcess.pid, "SIGKILL");
203
438
  }
@@ -206,7 +441,11 @@ export async function runCommand(options) {
206
441
  }
207
442
  }
208
443
  };
209
- process.on("exit", cleanupDebugger);
444
+ // Only register exit handler for non-CLI modes
445
+ // In CLI mode, we cleanup manually after agent exits
446
+ if (!cli) {
447
+ process.on("exit", cleanupDebugger);
448
+ }
210
449
  // Check if agent exists
211
450
  try {
212
451
  const { stat } = await import("node:fs/promises");
@@ -222,7 +461,7 @@ export async function runCommand(options) {
222
461
  const logger = createLogger("cli", "debug");
223
462
  logger.info("Starting agent", {
224
463
  name,
225
- mode: gui ? "gui" : http ? "http" : "tui",
464
+ mode: gui ? "gui" : http ? "http" : cli ? "cli" : "tui",
226
465
  agentPath,
227
466
  });
228
467
  // If GUI mode, run with tabbed interface
@@ -284,6 +523,12 @@ export async function runCommand(options) {
284
523
  ...configEnvVars,
285
524
  VITE_AGENT_URL: `http://localhost:${availablePort}`,
286
525
  VITE_DEBUGGER_URL: "http://localhost:4000",
526
+ // If agent uses bibliotecha MCP, pass BIBLIOTECHA_URL to GUI for auth
527
+ ...(usesBibliotechaMcp &&
528
+ process.env.BIBLIOTECHA_URL && {
529
+ VITE_BIBLIOTECHA_URL: process.env.BIBLIOTECHA_URL,
530
+ VITE_BIBLIOTECHA_FRONTEND_URL: process.env.BIBLIOTECHA_FRONTEND_URL,
531
+ }),
287
532
  },
288
533
  });
289
534
  // Setup cleanup handlers for agent and GUI processes
@@ -310,7 +555,7 @@ export async function runCommand(options) {
310
555
  }
311
556
  }
312
557
  // Also cleanup debugger
313
- cleanupDebugger();
558
+ cleanupDebugger?.();
314
559
  };
315
560
  process.on("exit", cleanupProcesses);
316
561
  // Handle SIGINT (Control-C) explicitly and exit
@@ -372,7 +617,7 @@ export async function runCommand(options) {
372
617
  }
373
618
  }
374
619
  // Also cleanup debugger
375
- cleanupDebugger();
620
+ cleanupDebugger?.();
376
621
  };
377
622
  process.on("exit", cleanupAgentProcess);
378
623
  // Handle SIGINT (Control-C) explicitly and exit
@@ -396,6 +641,30 @@ export async function runCommand(options) {
396
641
  });
397
642
  return;
398
643
  }
644
+ // CLI mode: Run with single prompt and output to stdout
645
+ if (cli) {
646
+ if (!prompt) {
647
+ console.error("Error: --cli mode requires a PROMPT argument");
648
+ console.log('\nUsage: town run <agent> --cli "your prompt here"');
649
+ process.exit(1);
650
+ }
651
+ logger.info("Starting CLI mode", { name, agentPath, prompt });
652
+ // Run CLI mode and wait for completion
653
+ await runCliMode({
654
+ binPath,
655
+ agentPath,
656
+ workingDir: agentPath,
657
+ prompt,
658
+ configEnvVars,
659
+ noSession,
660
+ waitForDebugger: true, // Wait for debugger to be ready before starting agent
661
+ });
662
+ // Agent has exited cleanly, now cleanup debugger
663
+ if (cleanupDebugger) {
664
+ cleanupDebugger();
665
+ }
666
+ process.exit(0);
667
+ }
399
668
  // Default: Start TUI interface with the agent
400
669
  logger.info("Starting TUI mode", { name, agentPath });
401
670
  // Clear terminal and move cursor to top
@@ -408,7 +677,7 @@ export async function runCommand(options) {
408
677
  }
409
678
  // Setup signal handlers for TUI mode
410
679
  const handleTuiSigint = () => {
411
- cleanupDebugger();
680
+ cleanupDebugger?.();
412
681
  process.exit(0);
413
682
  };
414
683
  process.on("SIGINT", handleTuiSigint);
@@ -416,7 +685,7 @@ export async function runCommand(options) {
416
685
  // Render the tabbed UI with Chat and Logs
417
686
  const { waitUntilExit } = render(_jsx(TuiRunner, { agentPath: binPath, workingDir: agentPath, noSession: noSession, onExit: () => {
418
687
  // Cleanup is handled by the ACP client disconnect
419
- cleanupDebugger();
688
+ cleanupDebugger?.();
420
689
  } }));
421
690
  await waitUntilExit();
422
691
  process.exit(0);
@@ -1,12 +1,12 @@
1
- import { loadAuthCredentials } from "@townco/core/auth";
1
+ import { getShedAuth } from "@townco/core/auth";
2
2
  import { authGet } from "../lib/auth-fetch.js";
3
3
  export async function whoamiCommand() {
4
- const credentials = loadAuthCredentials();
4
+ const credentials = getShedAuth();
5
5
  if (!credentials) {
6
- console.log("Not logged in. Run 'town login' to authenticate.");
6
+ console.log("Not logged in. Run 'town login' or set SHED_API_KEY to authenticate.");
7
7
  return;
8
8
  }
9
- console.log(`Shed URL: ${credentials.shed_url}`);
9
+ console.log(`Shed URL: ${credentials.shedUrl}`);
10
10
  console.log("Verifying credentials...\n");
11
11
  try {
12
12
  const response = await authGet("/api/cli/me");
@@ -25,7 +25,7 @@ export async function whoamiCommand() {
25
25
  }
26
26
  catch (error) {
27
27
  if (error instanceof TypeError && error.message.includes("fetch")) {
28
- console.error(`Could not connect to ${credentials.shed_url}`);
28
+ console.error(`Could not connect to ${credentials.shedUrl}`);
29
29
  console.error("Please check your internet connection and try again.");
30
30
  }
31
31
  else {
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import walk from "ignore-walk";
17
17
  import inquirer from "inquirer";
18
18
  import superjson from "superjson";
19
19
  import { match } from "ts-pattern";
20
+ import { batchCommand } from "./commands/batch.js";
20
21
  import { configureCommand } from "./commands/configure.js";
21
22
  import { createCommand } from "./commands/create.js";
22
23
  import { createProjectCommand } from "./commands/create-project.js";
@@ -64,9 +65,21 @@ const parser = or(command("deploy", object({ command: constant("deploy") }), {
64
65
  name: argument(string({ metavar: "NAME" })),
65
66
  http: optional(flag("--http")),
66
67
  gui: optional(flag("--gui")),
68
+ cli: optional(flag("--cli")),
69
+ prompt: optional(argument(string({ metavar: "PROMPT" }))),
67
70
  port: optional(option("-p", "--port", integer())),
68
71
  noSession: optional(flag("--no-session")),
69
- }), { brief: message `Run an agent.` }), command("secret", object({
72
+ }), { brief: message `Run an agent.` }), command("batch", object({
73
+ command: constant("batch"),
74
+ name: argument(string({ metavar: "NAME" })),
75
+ queries: multiple(argument(string({ metavar: "QUERY" }))),
76
+ file: optional(option("-f", "--file", string())),
77
+ concurrency: optional(option("-c", "--concurrency", integer())),
78
+ port: optional(option("-p", "--port", integer())),
79
+ }), {
80
+ brief: message `Run multiple queries in parallel against an agent.`,
81
+ description: message `Run multiple queries in parallel, each in its own session. Queries can be provided as arguments or loaded from a file.`,
82
+ }), command("secret", object({
70
83
  command: constant("secret"),
71
84
  subcommand: or(command("list", object({ action: constant("list") }), {
72
85
  brief: message `List secrets.`,
@@ -215,17 +228,39 @@ const main = async (parser, meta) => await match(run(parser, meta))
215
228
  }
216
229
  }
217
230
  })
218
- .with({ command: "run" }, async ({ name, http, gui, port, noSession }) => {
231
+ .with({ command: "run" }, async ({ name, http, gui, cli, prompt, port, noSession }) => {
219
232
  const options = {
220
233
  name,
221
234
  http: http === true,
222
235
  gui: gui === true,
236
+ cli: cli === true,
223
237
  noSession: noSession === true,
224
238
  };
239
+ if (prompt !== null && prompt !== undefined) {
240
+ options.prompt = prompt;
241
+ }
225
242
  if (port !== null && port !== undefined) {
226
243
  options.port = port;
227
244
  }
228
245
  await runCommand(options);
246
+ })
247
+ .with({ command: "batch" }, async ({ name, queries, file, concurrency, port }) => {
248
+ const options = {
249
+ name,
250
+ };
251
+ if (queries.length > 0) {
252
+ options.queries = [...queries]; // Convert readonly array to mutable
253
+ }
254
+ if (file !== null && file !== undefined) {
255
+ options.file = file;
256
+ }
257
+ if (concurrency !== null && concurrency !== undefined) {
258
+ options.concurrency = concurrency;
259
+ }
260
+ if (port !== null && port !== undefined) {
261
+ options.port = port;
262
+ }
263
+ await batchCommand(options);
229
264
  })
230
265
  .with({ command: "secret" }, async ({ subcommand }) => {
231
266
  await match(subcommand)
@@ -1,12 +1,14 @@
1
- import { type AuthCredentials } from "@townco/core/auth";
2
1
  /**
3
- * Get valid credentials, refreshing if necessary
4
- * Throws if not logged in or refresh fails
2
+ * Get valid credentials, refreshing if necessary.
3
+ * Checks disk credentials first, then falls back to SHED_API_KEY env var.
4
+ * Throws if not logged in or refresh fails.
5
5
  */
6
- export declare function getValidCredentials(): Promise<AuthCredentials>;
6
+ export declare function getValidCredentials(): Promise<{
7
+ accessToken: string;
8
+ shedUrl: string;
9
+ }>;
7
10
  /**
8
11
  * Make an authenticated fetch request to shed
9
- * Automatically refreshes token if expired
10
12
  */
11
13
  export declare function authFetch(path: string, options?: RequestInit): Promise<Response>;
12
14
  /**
@@ -1,4 +1,4 @@
1
- import { isTokenExpired, loadAuthCredentials, saveAuthCredentials, } from "@townco/core/auth";
1
+ import { getShedUrl, isTokenExpired, loadAuthCredentialsFromDisk, saveAuthCredentials, } from "@townco/core/auth";
2
2
  // ============================================================================
3
3
  // Token Refresh
4
4
  // ============================================================================
@@ -32,52 +32,53 @@ async function refreshAccessToken(credentials) {
32
32
  // Public API
33
33
  // ============================================================================
34
34
  /**
35
- * Get valid credentials, refreshing if necessary
36
- * Throws if not logged in or refresh fails
35
+ * Get valid credentials, refreshing if necessary.
36
+ * Checks disk credentials first, then falls back to SHED_API_KEY env var.
37
+ * Throws if not logged in or refresh fails.
37
38
  */
38
39
  export async function getValidCredentials() {
39
- const credentials = loadAuthCredentials();
40
- if (!credentials) {
41
- throw new Error("Not logged in. Run 'town login' first.");
42
- }
43
- if (isTokenExpired(credentials)) {
44
- try {
45
- return await refreshAccessToken(credentials);
46
- }
47
- catch (error) {
48
- throw new Error(`Session expired. Please run 'town login' again. (${error instanceof Error ? error.message : String(error)})`);
40
+ // Try disk credentials first
41
+ const credentials = loadAuthCredentialsFromDisk();
42
+ if (credentials) {
43
+ if (isTokenExpired(credentials)) {
44
+ try {
45
+ const refreshed = await refreshAccessToken(credentials);
46
+ return {
47
+ accessToken: refreshed.access_token,
48
+ shedUrl: refreshed.shed_url,
49
+ };
50
+ }
51
+ catch (error) {
52
+ throw new Error(`Session expired. Please run 'town login' again. (${error instanceof Error ? error.message : String(error)})`);
53
+ }
49
54
  }
55
+ return {
56
+ accessToken: credentials.access_token,
57
+ shedUrl: credentials.shed_url,
58
+ };
59
+ }
60
+ // Fall back to SHED_API_KEY
61
+ const apiKey = process.env.SHED_API_KEY;
62
+ if (apiKey) {
63
+ return {
64
+ accessToken: apiKey,
65
+ shedUrl: getShedUrl(),
66
+ };
50
67
  }
51
- return credentials;
68
+ throw new Error("Not logged in. Run 'town login' first.");
52
69
  }
53
70
  /**
54
71
  * Make an authenticated fetch request to shed
55
- * Automatically refreshes token if expired
56
72
  */
57
73
  export async function authFetch(path, options = {}) {
58
- let credentials = await getValidCredentials();
59
- const url = path.startsWith("http") ? path : `${credentials.shed_url}${path}`;
74
+ const credentials = await getValidCredentials();
75
+ const url = path.startsWith("http") ? path : `${credentials.shedUrl}${path}`;
60
76
  const headers = new Headers(options.headers);
61
- headers.set("Authorization", `Bearer ${credentials.access_token}`);
62
- const response = await fetch(url, {
77
+ headers.set("Authorization", `Bearer ${credentials.accessToken}`);
78
+ return fetch(url, {
63
79
  ...options,
64
80
  headers,
65
81
  });
66
- // If we get 401, try refreshing once
67
- if (response.status === 401) {
68
- try {
69
- credentials = await refreshAccessToken(credentials);
70
- headers.set("Authorization", `Bearer ${credentials.access_token}`);
71
- return fetch(url, {
72
- ...options,
73
- headers,
74
- });
75
- }
76
- catch {
77
- throw new Error("Session expired. Please run 'town login' again.");
78
- }
79
- }
80
- return response;
81
82
  }
82
83
  /**
83
84
  * Convenience method for GET requests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "town": "./dist/index.js"
@@ -15,7 +15,7 @@
15
15
  "build": "tsc"
16
16
  },
17
17
  "devDependencies": {
18
- "@townco/tsconfig": "0.1.65",
18
+ "@townco/tsconfig": "0.1.67",
19
19
  "@types/archiver": "^7.0.0",
20
20
  "@types/bun": "^1.3.1",
21
21
  "@types/ignore-walk": "^4.0.3",
@@ -25,12 +25,12 @@
25
25
  "dependencies": {
26
26
  "@optique/core": "^0.6.2",
27
27
  "@optique/run": "^0.6.2",
28
- "@townco/agent": "0.1.73",
29
- "@townco/core": "0.0.46",
30
- "@townco/debugger": "0.1.23",
31
- "@townco/env": "0.1.18",
32
- "@townco/secret": "0.1.68",
33
- "@townco/ui": "0.1.68",
28
+ "@townco/agent": "0.1.75",
29
+ "@townco/core": "0.0.48",
30
+ "@townco/debugger": "0.1.25",
31
+ "@townco/env": "0.1.20",
32
+ "@townco/secret": "0.1.70",
33
+ "@townco/ui": "0.1.70",
34
34
  "@trpc/client": "^11.7.2",
35
35
  "@types/inquirer": "^9.0.9",
36
36
  "@types/ws": "^8.5.13",