@townco/cli 0.1.13 → 0.1.15

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.
@@ -1,9 +1,8 @@
1
1
  interface CreateCommandProps {
2
- name?: string;
3
- model?: string;
4
- tools?: readonly string[];
5
- systemPrompt?: string;
6
- overwrite?: boolean;
2
+ name?: string;
3
+ model?: string;
4
+ tools?: readonly string[];
5
+ systemPrompt?: string;
6
+ overwrite?: boolean;
7
7
  }
8
8
  export declare function createCommand(props: CreateCommandProps): Promise<void>;
9
- export {};
@@ -131,20 +131,10 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
131
131
  if (result.success) {
132
132
  setScaffoldStatus("done");
133
133
  setAgentPath(result.path);
134
- // Output in the same format as 'town list'
135
- const modelLabel = model.replace("claude-", "");
136
- console.log(`\n\x1b[1mAgent created successfully!\x1b[0m\n`);
137
- console.log(` \x1b[32m●\x1b[0m \x1b[1m${name}\x1b[0m`);
138
- console.log(` \x1b[2mModel: ${modelLabel}\x1b[0m`);
139
- if (agentDef.tools && agentDef.tools.length > 0) {
140
- console.log(` \x1b[2mTools: ${agentDef.tools.join(", ")}\x1b[0m`);
141
- }
142
- console.log(` \x1b[2mPath: ${result.path}\x1b[0m`);
143
- console.log();
144
- console.log(`\x1b[2mRun an agent with: town run ${agentDef.name}\x1b[0m`);
145
- console.log(`\x1b[2mTUI mode (default), --gui for web interface, --http for API server\x1b[0m`);
146
- // Exit immediately
147
- process.exit(0);
134
+ // Exit after showing the done message
135
+ setTimeout(() => {
136
+ process.exit(0);
137
+ }, 100);
148
138
  }
149
139
  else {
150
140
  setScaffoldStatus("error");
@@ -297,13 +287,14 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
297
287
  // Done stage
298
288
  if (stage === "done") {
299
289
  if (scaffoldStatus === "scaffolding") {
300
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "\u23F3 Creating agent package..." }) }));
290
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: ["\u23F3 Creating agent ", agentDef.name, "..."] }) }));
301
291
  }
302
292
  if (scaffoldStatus === "error") {
303
293
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "\u2717 Error creating agent package" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: scaffoldError }) })] }));
304
294
  }
305
295
  if (scaffoldStatus === "done") {
306
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", children: "\u2713 Agent package created successfully!" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Path: ", agentPath] }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["You can run the agent with:", " ", _jsxs(Text, { bold: true, color: "cyan", children: ["town run ", agentDef.name] })] }) })] }));
296
+ const modelLabel = agentDef.model?.replace("claude-", "") || "";
297
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Agent created successfully!" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u25CF" }), " ", _jsx(Text, { bold: true, children: agentDef.name })] }), _jsxs(Text, { dimColor: true, children: [" Model: ", modelLabel] }), agentDef.tools && agentDef.tools.length > 0 && (_jsxs(Text, { dimColor: true, children: [" Tools: ", agentDef.tools.join(", ")] })), _jsxs(Text, { dimColor: true, children: [" Path: ", agentPath] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Run an agent with: town run ", agentDef.name] }), _jsx(Text, { dimColor: true, children: "TUI mode (default), --gui for web interface, --http for API server" })] })] }));
307
298
  }
308
299
  }
309
300
  return null;
@@ -1,9 +1,14 @@
1
1
  interface MCPAddProps {
2
- name?: string;
3
- url?: string;
4
- command?: string;
5
- args?: readonly string[];
2
+ name?: string;
3
+ url?: string;
4
+ command?: string;
5
+ args?: readonly string[];
6
6
  }
7
- declare function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand, args: initialArgs, }: MCPAddProps): import("react/jsx-runtime").JSX.Element | null;
7
+ declare function MCPAddApp({
8
+ name: initialName,
9
+ url: initialUrl,
10
+ command: initialCommand,
11
+ args: initialArgs,
12
+ }: MCPAddProps): import("react/jsx-runtime").JSX.Element | null;
8
13
  export default MCPAddApp;
9
14
  export declare function runMCPAdd(props?: MCPAddProps): Promise<void>;
@@ -102,6 +102,34 @@ function HttpUrlStage({ serverName, value, onChange, onNext, onBack, onError, })
102
102
  };
103
103
  return (_jsx(TextInputStage, { title: `Enter HTTP URL for MCP server: ${serverName}`, value: value, onChange: onChange, onSubmit: handleSubmit, onCancel: onBack, placeholder: "http://localhost:3000/mcp" }));
104
104
  }
105
+ function HttpHeadersStage({ serverName, headers, onAddHeader, onNext, onBack, }) {
106
+ const [headerKeyInput, setHeaderKeyInput] = useState("");
107
+ const [headerValueInput, setHeaderValueInput] = useState("");
108
+ const [inputStage, setInputStage] = useState("key");
109
+ const headerEntries = Object.entries(headers);
110
+ useInput((_input, key) => {
111
+ if (key.escape && inputStage === "key") {
112
+ onBack();
113
+ }
114
+ });
115
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Add HTTP headers for: ", serverName, " (", headerEntries.length, " added)"] }) }), headerEntries.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Current headers:" }), headerEntries.map(([key, value], index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { children: [index + 1, ". ", key, ": ", value] }) }, key)))] })), inputStage === "key" && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { children: [">", " Header name: "] }), _jsx(TextInput, { value: headerKeyInput, onChange: setHeaderKeyInput, onSubmit: () => {
116
+ const trimmed = headerKeyInput.trim();
117
+ if (trimmed) {
118
+ setInputStage("value");
119
+ }
120
+ else {
121
+ onNext();
122
+ }
123
+ }, placeholder: "Enter header name (or leave empty to continue)" })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Enter: Submit header name or continue \u2022 Esc: Back" }) })] })), inputStage === "value" && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { children: [">", " Value for ", headerKeyInput, ":", " "] }), _jsx(TextInput, { value: headerValueInput, onChange: setHeaderValueInput, onSubmit: () => {
124
+ const trimmedValue = headerValueInput.trim();
125
+ if (trimmedValue) {
126
+ onAddHeader(headerKeyInput, trimmedValue);
127
+ setHeaderKeyInput("");
128
+ setHeaderValueInput("");
129
+ setInputStage("key");
130
+ }
131
+ }, placeholder: "Header value" })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Enter: Add header" }) })] }))] }));
132
+ }
105
133
  function ReviewStage({ config, onEdit, onSave, onBack }) {
106
134
  const [reviewSelection, setReviewSelection] = useState(null);
107
135
  const reviewOptions = [
@@ -137,9 +165,13 @@ function ReviewStage({ config, onEdit, onSave, onBack }) {
137
165
  label: "Edit URL",
138
166
  value: "httpUrl",
139
167
  description: "Change the HTTP URL",
168
+ }, {
169
+ label: "Edit headers",
170
+ value: "httpHeaders",
171
+ description: "Change the HTTP headers",
140
172
  });
141
173
  }
142
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Review MCP Server Configuration:" }) }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Name: " }), config.name] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Transport: " }), config.transport] }), config.transport === "stdio" && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Command: " }), config.command] }), config.args && config.args.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Arguments:" }), config.args.map((arg, index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: [index + 1, ". ", arg] }) }, arg)))] }))] })), config.transport === "http" && (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "URL: " }), config.url] }))] }), _jsx(SingleSelect, { options: reviewOptions, selected: reviewSelection, onChange: setReviewSelection, onSubmit: (value) => {
174
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Review MCP Server Configuration:" }) }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Name: " }), config.name] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Transport: " }), config.transport] }), config.transport === "stdio" && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Command: " }), config.command] }), config.args && config.args.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Arguments:" }), config.args.map((arg, index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: [index + 1, ". ", arg] }) }, arg)))] }))] })), config.transport === "http" && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "URL: " }), config.url] }), config.headers && Object.keys(config.headers).length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Headers:" }), Object.entries(config.headers).map(([key, value], index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: [index + 1, ". ", key, ": ", value] }) }, key)))] }))] }))] }), _jsx(SingleSelect, { options: reviewOptions, selected: reviewSelection, onChange: setReviewSelection, onSubmit: (value) => {
143
175
  setReviewSelection(value);
144
176
  if (value === "continue") {
145
177
  onSave();
@@ -148,7 +180,8 @@ function ReviewStage({ config, onEdit, onSave, onBack }) {
148
180
  value === "transport" ||
149
181
  value === "stdioCommand" ||
150
182
  value === "stdioArgs" ||
151
- value === "httpUrl") {
183
+ value === "httpUrl" ||
184
+ value === "httpHeaders") {
152
185
  onEdit(value);
153
186
  }
154
187
  }, onCancel: onBack })] }));
@@ -228,7 +261,7 @@ function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand
228
261
  }
229
262
  // If URL provided, transport is http
230
263
  if (initialUrl) {
231
- return initialName ? "review" : "name";
264
+ return initialName ? "httpHeaders" : "name";
232
265
  }
233
266
  return "transport";
234
267
  };
@@ -278,6 +311,7 @@ function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand
278
311
  }),
279
312
  ...(config.transport === "http" && {
280
313
  url: config.url,
314
+ ...(config.headers && { headers: config.headers }),
281
315
  }),
282
316
  };
283
317
  // Save to global MCP storage
@@ -390,8 +424,11 @@ function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand
390
424
  setConfig({ ...config, url });
391
425
  if (isEditingFromReview) {
392
426
  setIsEditingFromReview(false);
427
+ setStage("review");
428
+ }
429
+ else {
430
+ setStage("httpHeaders");
393
431
  }
394
- setStage("review");
395
432
  }, onBack: () => {
396
433
  if (initialUrl) {
397
434
  exit();
@@ -401,6 +438,18 @@ function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand
401
438
  }
402
439
  }, onError: setSaveError }));
403
440
  }
441
+ // HTTP Headers input stage
442
+ if (stage === "httpHeaders") {
443
+ return (_jsx(HttpHeadersStage, { serverName: config.name || "", headers: config.headers || {}, onAddHeader: (key, value) => setConfig({
444
+ ...config,
445
+ headers: { ...(config.headers || {}), [key]: value },
446
+ }), onNext: () => {
447
+ if (isEditingFromReview) {
448
+ setIsEditingFromReview(false);
449
+ }
450
+ setStage("review");
451
+ }, onBack: () => setStage("httpUrl") }));
452
+ }
404
453
  // Review stage
405
454
  if (stage === "review") {
406
455
  return (_jsx(ReviewStage, { config: config, onEdit: (editStage) => {
@@ -413,7 +462,7 @@ function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand
413
462
  setStage("stdioArgs");
414
463
  }
415
464
  else {
416
- setStage("httpUrl");
465
+ setStage("httpHeaders");
417
466
  }
418
467
  } }));
419
468
  }
@@ -42,7 +42,9 @@ function MCPListApp() {
42
42
  if (!result || result.configs.length === 0) {
43
43
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "No MCP servers configured" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Add one with: town mcp add" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Press Enter or Q to exit" }) })] }));
44
44
  }
45
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Configured MCP Servers (", result.configs.length, ")"] }) }), result.configs.map((config, index) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { children: _jsxs(Text, { bold: true, color: "cyan", children: [index + 1, ". ", config.name] }) }), _jsx(Box, { paddingLeft: 3, children: config.transport === "http" ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Transport: ", _jsx(Text, { color: "green", children: "HTTP" })] }), _jsxs(Text, { children: ["URL: ", config.url] })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Transport: ", _jsx(Text, { color: "blue", children: "stdio" })] }), _jsxs(Text, { children: ["Command: ", config.command] }), config.args && config.args.length > 0 && (_jsxs(Text, { children: ["Args: ", config.args.join(" ")] }))] })) })] }, config.name))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Use `town mcp remove` to remove a server or `town mcp add` to add one" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Q to exit" }) })] }));
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Configured MCP Servers (", result.configs.length, ")"] }) }), result.configs.map((config, index) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { children: _jsxs(Text, { bold: true, color: "cyan", children: [index + 1, ". ", config.name] }) }), _jsx(Box, { paddingLeft: 3, children: config.transport === "http" ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Transport: ", _jsx(Text, { color: "green", children: "HTTP" })] }), _jsxs(Text, { children: ["URL: ", config.url] }), config.headers && Object.keys(config.headers).length > 0 && (_jsxs(Text, { children: ["Headers:", " ", Object.entries(config.headers)
46
+ .map(([key, value]) => `${key}: ${value}`)
47
+ .join(", ")] }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Transport: ", _jsx(Text, { color: "blue", children: "stdio" })] }), _jsxs(Text, { children: ["Command: ", config.command] }), config.args && config.args.length > 0 && (_jsxs(Text, { children: ["Args: ", config.args.join(" ")] }))] })) })] }, config.name))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Use `town mcp remove` to remove a server or `town mcp add` to add one" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Q to exit" }) })] }));
46
48
  }
47
49
  // ============================================================================
48
50
  // Export and Runner
@@ -1,10 +1,40 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
1
2
  import { spawn } from "node:child_process";
2
3
  import { existsSync } from "node:fs";
3
4
  import { readFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { agentExists, getAgentPath } from "@townco/agent/storage";
8
+ import { render } from "ink";
7
9
  import open from "open";
10
+ import { useCallback, useMemo, useRef } from "react";
11
+ import { TabbedOutput } from "../components/TabbedOutput.js";
12
+ import { findAvailablePort } from "../lib/port-utils.js";
13
+ function GuiRunner({ agentProcess, guiProcess, agentPort, onExit, }) {
14
+ const browserOpenedRef = useRef(false);
15
+ const handlePortDetected = useCallback((processIndex, port) => {
16
+ // Process index 1 is the GUI process
17
+ if (processIndex === 1) {
18
+ // Open browser once we know the actual port
19
+ if (!browserOpenedRef.current) {
20
+ browserOpenedRef.current = true;
21
+ const guiUrl = `http://localhost:${port}`;
22
+ open(guiUrl).catch((error) => {
23
+ // Silently fail - user can open manually
24
+ console.warn(`Could not automatically open browser: ${error.message}`);
25
+ });
26
+ }
27
+ }
28
+ }, []);
29
+ // Memoize processes array based only on actual process objects and initial ports
30
+ // Don't include guiPort as dependency to prevent re-creating array when port is detected
31
+ // TabbedOutput will update the displayed port internally via onPortDetected callback
32
+ const processes = useMemo(() => [
33
+ { name: "Agent", process: agentProcess, port: agentPort },
34
+ { name: "GUI", process: guiProcess }, // Port will be detected dynamically
35
+ ], [agentProcess, guiProcess, agentPort]);
36
+ return (_jsx(TabbedOutput, { processes: processes, onExit: onExit, onPortDetected: handlePortDetected }));
37
+ }
8
38
  async function loadEnvVars() {
9
39
  const envPath = join(homedir(), ".config", "town", ".env");
10
40
  const envVars = {};
@@ -42,7 +72,7 @@ export async function runCommand(options) {
42
72
  }
43
73
  const agentPath = getAgentPath(name);
44
74
  const binPath = join(agentPath, "bin.ts");
45
- // If GUI or HTTP-only mode, run differently
75
+ // If GUI mode, run with tabbed interface
46
76
  if (gui) {
47
77
  const guiPath = join(agentPath, "gui");
48
78
  // Check if GUI exists
@@ -56,72 +86,58 @@ export async function runCommand(options) {
56
86
  console.log(`Recreate the agent with "town create" to include the GUI.`);
57
87
  process.exit(1);
58
88
  }
89
+ // Find an available port for the agent
90
+ const availablePort = await findAvailablePort(port);
91
+ if (availablePort !== port) {
92
+ console.log(`Port ${port} is in use, using port ${availablePort} instead`);
93
+ }
59
94
  console.log(`Starting agent "${name}" with GUI...`);
60
- console.log(`Starting agent HTTP server on port ${port}...`);
61
- console.log(`Starting GUI development server...\n`);
62
- // Start the agent in HTTP mode first
95
+ console.log(`Agent HTTP server will run on port ${availablePort}`);
96
+ console.log(`GUI dev server will run on port 5173\n`);
97
+ // Set stdin to raw mode for Ink
98
+ if (process.stdin.isTTY) {
99
+ process.stdin.setRawMode(true);
100
+ }
101
+ // Start the agent in HTTP mode
63
102
  const agentProcess = spawn("bun", [binPath, "http"], {
64
103
  cwd: agentPath,
65
- stdio: "pipe",
104
+ stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
66
105
  env: {
67
106
  ...process.env,
68
107
  ...configEnvVars,
69
108
  NODE_ENV: process.env.NODE_ENV || "production",
70
- PORT: port.toString(),
109
+ PORT: availablePort.toString(),
71
110
  },
72
111
  });
73
- agentProcess.on("error", (error) => {
74
- console.error(`Failed to start agent: ${error.message}`);
75
- process.exit(1);
112
+ // Start the GUI dev server
113
+ const guiProcess = spawn("bun", ["run", "dev"], {
114
+ cwd: guiPath,
115
+ stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
116
+ env: {
117
+ ...process.env,
118
+ ...configEnvVars,
119
+ VITE_AGENT_URL: `http://localhost:${availablePort}`,
120
+ },
76
121
  });
77
- // Wait a bit for agent to start, then start the GUI
78
- setTimeout(() => {
79
- // Start the GUI dev server
80
- const guiProcess = spawn("bun", ["run", "dev"], {
81
- cwd: guiPath,
82
- stdio: "inherit",
83
- env: {
84
- ...process.env,
85
- ...configEnvVars,
86
- VITE_AGENT_URL: `http://localhost:${port}`,
87
- },
88
- });
89
- guiProcess.on("error", (error) => {
90
- console.error(`Failed to start GUI: ${error.message}`);
91
- agentProcess.kill();
92
- process.exit(1);
93
- });
94
- guiProcess.on("close", (code) => {
122
+ // Render the tabbed UI with dynamic port detection
123
+ const { waitUntilExit } = render(_jsx(GuiRunner, { agentProcess: agentProcess, guiProcess: guiProcess, agentPort: availablePort, onExit: () => {
95
124
  agentProcess.kill();
96
- if (code !== 0 && code !== null) {
97
- console.error(`GUI exited with code ${code}`);
98
- process.exit(code);
99
- }
100
- });
101
- // Open browser after GUI server has time to start (default Vite port is 5173)
102
- setTimeout(() => {
103
- const guiUrl = "http://localhost:5173";
104
- console.log(`Opening browser at ${guiUrl}...`);
105
- open(guiUrl).catch((error) => {
106
- console.warn(`Could not automatically open browser: ${error.message}`);
107
- console.log(`Please manually open: ${guiUrl}`);
108
- });
109
- }, 2000);
110
- }, 1000);
111
- agentProcess.on("close", (code) => {
112
- if (code !== 0 && code !== null) {
113
- console.error(`Agent exited with code ${code}`);
114
- process.exit(code);
115
- }
116
- });
117
- return;
125
+ guiProcess.kill();
126
+ } }));
127
+ await waitUntilExit();
128
+ process.exit(0);
118
129
  }
119
130
  else if (http) {
120
- console.log(`Starting agent "${name}" in HTTP mode on port ${port}...`);
131
+ // Find an available port for the agent
132
+ const availablePort = await findAvailablePort(port);
133
+ if (availablePort !== port) {
134
+ console.log(`Port ${port} is in use, using port ${availablePort} instead\n`);
135
+ }
136
+ console.log(`Starting agent "${name}" in HTTP mode on port ${availablePort}...`);
121
137
  console.log(`\nEndpoints:`);
122
- console.log(` http://localhost:${port}/health - Health check`);
123
- console.log(` http://localhost:${port}/rpc - RPC endpoint`);
124
- console.log(` http://localhost:${port}/events - SSE event stream\n`);
138
+ console.log(` http://localhost:${availablePort}/health - Health check`);
139
+ console.log(` http://localhost:${availablePort}/rpc - RPC endpoint`);
140
+ console.log(` http://localhost:${availablePort}/events - SSE event stream\n`);
125
141
  // Run the agent in HTTP mode
126
142
  const agentProcess = spawn("bun", [binPath, "http"], {
127
143
  cwd: agentPath,
@@ -130,7 +146,7 @@ export async function runCommand(options) {
130
146
  ...process.env,
131
147
  ...configEnvVars,
132
148
  NODE_ENV: process.env.NODE_ENV || "production",
133
- PORT: port.toString(),
149
+ PORT: availablePort.toString(),
134
150
  },
135
151
  });
136
152
  agentProcess.on("error", (error) => {
@@ -0,0 +1,7 @@
1
+ export interface ProcessPaneProps {
2
+ title: string;
3
+ output: string[];
4
+ port: number | undefined;
5
+ status: "starting" | "running" | "stopped" | "error";
6
+ }
7
+ export declare function ProcessPane({ title, output, port, status }: ProcessPaneProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useMemo } from "react";
4
+ export function ProcessPane({ title, output, port, status }) {
5
+ // Keep only last 50 lines to prevent memory issues
6
+ const displayOutput = useMemo(() => {
7
+ return output.slice(-50);
8
+ }, [output]);
9
+ const statusColor = status === "running"
10
+ ? "green"
11
+ : status === "error"
12
+ ? "red"
13
+ : status === "starting"
14
+ ? "yellow"
15
+ : "gray";
16
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: "cyan", bold: true, children: title }), port && _jsxs(Text, { color: "gray", children: [" - http://localhost:", port] }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", status] })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, paddingX: 1, children: displayOutput.length === 0 ? (_jsx(Text, { color: "gray", children: "Waiting for output..." })) : (displayOutput.map((line, idx) => (_jsx(Text, { children: line }, `${idx}-${line.slice(0, 20)}`)))) }), _jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexShrink: 0, children: _jsxs(Text, { color: "gray", children: ["Lines: ", output.length, " | Displaying last ", displayOutput.length] }) })] }));
17
+ }
@@ -0,0 +1,5 @@
1
+ export interface StatusLineProps {
2
+ activeTab: number;
3
+ tabs: string[];
4
+ }
5
+ export declare function StatusLine({ activeTab, tabs }: StatusLineProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export function StatusLine({ activeTab, tabs }) {
4
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1, children: [tabs.map((tab, idx) => (_jsxs(Text, { children: [_jsxs(Text, { color: activeTab === idx ? "green" : "gray", children: [idx + 1, ". ", tab] }), idx < tabs.length - 1 && _jsx(Text, { color: "gray", children: " | " })] }, tab))), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: "yellow", children: "tab" }), _jsx(Text, { color: "gray", children: ", " }), _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), _jsx(Text, { color: "gray", children: " or " }), _jsxs(Text, { color: "yellow", children: ["1-", tabs.length] }), _jsx(Text, { color: "gray", children: " to switch" })] }));
5
+ }
@@ -0,0 +1,12 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ export interface ProcessInfo {
3
+ name: string;
4
+ process: ChildProcess;
5
+ port?: number;
6
+ }
7
+ export interface TabbedOutputProps {
8
+ processes: ProcessInfo[];
9
+ onExit: () => void;
10
+ onPortDetected?: (processIndex: number, port: number) => void;
11
+ }
12
+ export declare function TabbedOutput({ processes, onExit, onPortDetected, }: TabbedOutputProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, useApp, useInput } from "ink";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { ProcessPane } from "./ProcessPane.js";
5
+ import { StatusLine } from "./StatusLine.js";
6
+ export function TabbedOutput({ processes, onExit, onPortDetected, }) {
7
+ const { exit } = useApp();
8
+ const [activeTab, setActiveTab] = useState(0);
9
+ const [outputs, setOutputs] = useState(processes.map(() => []));
10
+ const [statuses, setStatuses] = useState(processes.map(() => "starting"));
11
+ const [ports, setPorts] = useState(processes.map((p) => p.port));
12
+ const portDetectedRef = useRef(new Set());
13
+ // Handle keyboard input
14
+ useInput((input, key) => {
15
+ if (key.ctrl && input === "c") {
16
+ // Cleanup and exit
17
+ onExit();
18
+ exit();
19
+ return;
20
+ }
21
+ // Tab key to toggle between tabs
22
+ if (key.tab) {
23
+ setActiveTab((activeTab + 1) % processes.length);
24
+ return;
25
+ }
26
+ if (key.leftArrow && activeTab > 0) {
27
+ setActiveTab(activeTab - 1);
28
+ }
29
+ else if (key.rightArrow && activeTab < processes.length - 1) {
30
+ setActiveTab(activeTab + 1);
31
+ }
32
+ // Number keys to jump to tabs
33
+ const num = Number.parseInt(input, 10);
34
+ if (!Number.isNaN(num) && num >= 1 && num <= processes.length) {
35
+ setActiveTab(num - 1);
36
+ }
37
+ });
38
+ // Set up process output listeners
39
+ useEffect(() => {
40
+ processes.forEach((processInfo, idx) => {
41
+ const { process } = processInfo;
42
+ // Mark as running when we see first output
43
+ const markAsRunning = () => {
44
+ setStatuses((prev) => {
45
+ const newStatuses = [...prev];
46
+ if (newStatuses[idx] === "starting") {
47
+ newStatuses[idx] = "running";
48
+ }
49
+ return newStatuses;
50
+ });
51
+ };
52
+ // Capture stdout
53
+ process.stdout?.on("data", (data) => {
54
+ const output = data.toString();
55
+ const lines = output.split("\n").filter(Boolean);
56
+ setOutputs((prev) => {
57
+ const newOutputs = [...prev];
58
+ newOutputs[idx] = [...(newOutputs[idx] || []), ...lines];
59
+ return newOutputs;
60
+ });
61
+ markAsRunning();
62
+ // Check for Vite port in output and notify if callback provided
63
+ if (onPortDetected && !portDetectedRef.current.has(idx)) {
64
+ const portMatch = output.match(/Local:\s+http:\/\/localhost:(\d+)/);
65
+ if (portMatch?.[1]) {
66
+ const detectedPort = Number.parseInt(portMatch[1], 10);
67
+ portDetectedRef.current.add(idx);
68
+ // Update our internal ports state
69
+ setPorts((prev) => {
70
+ const newPorts = [...prev];
71
+ newPorts[idx] = detectedPort;
72
+ return newPorts;
73
+ });
74
+ onPortDetected(idx, detectedPort);
75
+ }
76
+ }
77
+ });
78
+ // Capture stderr
79
+ process.stderr?.on("data", (data) => {
80
+ const text = data.toString();
81
+ const lines = text.split("\n").filter(Boolean);
82
+ const errorLines = lines.map((line) => `[ERROR] ${line}`);
83
+ setOutputs((prev) => {
84
+ const newOutputs = [...prev];
85
+ newOutputs[idx] = [...(newOutputs[idx] || []), ...errorLines];
86
+ return newOutputs;
87
+ });
88
+ markAsRunning();
89
+ });
90
+ // Handle process exit
91
+ process.on("close", (code) => {
92
+ setStatuses((prev) => {
93
+ const newStatuses = [...prev];
94
+ newStatuses[idx] = code === 0 ? "stopped" : "error";
95
+ return newStatuses;
96
+ });
97
+ if (code !== 0 && code !== null) {
98
+ setOutputs((prev) => {
99
+ const newOutputs = [...prev];
100
+ newOutputs[idx] = [
101
+ ...(newOutputs[idx] || []),
102
+ `\n[Process exited with code ${code}]`,
103
+ ];
104
+ return newOutputs;
105
+ });
106
+ }
107
+ });
108
+ process.on("error", (error) => {
109
+ setStatuses((prev) => {
110
+ const newStatuses = [...prev];
111
+ newStatuses[idx] = "error";
112
+ return newStatuses;
113
+ });
114
+ setOutputs((prev) => {
115
+ const newOutputs = [...prev];
116
+ newOutputs[idx] = [
117
+ ...(newOutputs[idx] || []),
118
+ `\n[Process error: ${error.message}]`,
119
+ ];
120
+ return newOutputs;
121
+ });
122
+ });
123
+ });
124
+ // Cleanup function
125
+ return () => {
126
+ processes.forEach(({ process }) => {
127
+ process.kill();
128
+ });
129
+ };
130
+ }, [processes, onPortDetected]);
131
+ const currentProcess = processes[activeTab];
132
+ if (!currentProcess) {
133
+ return null;
134
+ }
135
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(StatusLine, { activeTab: activeTab, tabs: processes.map((p) => p.name) }), _jsx(ProcessPane, { title: currentProcess.name, output: outputs[activeTab] || [], port: ports[activeTab], status: statuses[activeTab] || "starting" })] }));
136
+ }
package/dist/index.js CHANGED
File without changes
@@ -4,6 +4,7 @@ export type MCPConfig = {
4
4
  command?: string;
5
5
  args?: string[];
6
6
  url?: string;
7
+ headers?: Record<string, string>;
7
8
  };
8
9
  /**
9
10
  * Save an MCP config to the store
@@ -95,7 +95,11 @@ export function mcpConfigExists(name) {
95
95
  */
96
96
  export function getMCPSummary(config) {
97
97
  if (config.transport === "http") {
98
- return `HTTP: ${config.url}`;
98
+ const parts = [`HTTP: ${config.url}`];
99
+ if (config.headers && Object.keys(config.headers).length > 0) {
100
+ parts.push(`(${Object.keys(config.headers).length} header${Object.keys(config.headers).length === 1 ? "" : "s"})`);
101
+ }
102
+ return parts.join(" ");
99
103
  }
100
104
  else {
101
105
  const parts = [`Stdio: ${config.command}`];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Check if a port is available
3
+ */
4
+ export declare function isPortAvailable(port: number): Promise<boolean>;
5
+ /**
6
+ * Find the next available port starting from the given port
7
+ */
8
+ export declare function findAvailablePort(startPort: number, maxAttempts?: number): Promise<number>;
@@ -0,0 +1,35 @@
1
+ import { createServer } from "node:net";
2
+ /**
3
+ * Check if a port is available
4
+ */
5
+ export async function isPortAvailable(port) {
6
+ return new Promise((resolve) => {
7
+ const server = createServer();
8
+ server.once("error", (err) => {
9
+ if (err.code === "EADDRINUSE") {
10
+ resolve(false);
11
+ }
12
+ else {
13
+ resolve(false);
14
+ }
15
+ });
16
+ server.once("listening", () => {
17
+ server.close();
18
+ resolve(true);
19
+ });
20
+ server.listen(port);
21
+ });
22
+ }
23
+ /**
24
+ * Find the next available port starting from the given port
25
+ */
26
+ export async function findAvailablePort(startPort, maxAttempts = 10) {
27
+ for (let i = 0; i < maxAttempts; i++) {
28
+ const port = startPort + i;
29
+ const available = await isPortAvailable(port);
30
+ if (available) {
31
+ return port;
32
+ }
33
+ }
34
+ throw new Error(`Could not find an available port between ${startPort} and ${startPort + maxAttempts - 1}`);
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "town": "./dist/index.js"
@@ -19,16 +19,16 @@
19
19
  "build": "tsc"
20
20
  },
21
21
  "devDependencies": {
22
- "@townco/tsconfig": "0.1.5",
22
+ "@townco/tsconfig": "0.1.7",
23
23
  "@types/bun": "^1.3.1",
24
24
  "@types/react": "^19.2.2"
25
25
  },
26
26
  "dependencies": {
27
27
  "@optique/core": "^0.6.2",
28
28
  "@optique/run": "^0.6.2",
29
- "@townco/agent": "0.1.13",
30
- "@townco/secret": "0.1.8",
31
- "@townco/ui": "0.1.8",
29
+ "@townco/agent": "0.1.15",
30
+ "@townco/secret": "0.1.10",
31
+ "@townco/ui": "0.1.10",
32
32
  "@types/inquirer": "^9.0.9",
33
33
  "ink": "^6.4.0",
34
34
  "ink-text-input": "^6.0.0",