@townco/cli 0.1.4 → 0.1.5

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,188 @@
1
+ import { Box, render, Text, useApp, useInput } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ import { listMCPConfigs } from "../lib/mcp-storage";
5
+
6
+ // ============================================================================
7
+ // Main Component
8
+ // ============================================================================
9
+ function MCPListApp() {
10
+ const [result, setResult] = useState(null);
11
+ const [loading, setLoading] = useState(true);
12
+ const { exit } = useApp();
13
+ useEffect(() => {
14
+ function loadConfigs() {
15
+ try {
16
+ const configs = listMCPConfigs();
17
+ setResult({ configs });
18
+ } catch (error) {
19
+ const errorMsg = error instanceof Error ? error.message : String(error);
20
+ setResult({ configs: [], error: errorMsg });
21
+ } finally {
22
+ setLoading(false);
23
+ }
24
+ }
25
+ loadConfigs();
26
+ }, []);
27
+ // Exit on any key press when not loading
28
+ useInput((_input, key) => {
29
+ if (!loading) {
30
+ if (key.return || key.escape || _input === "q") {
31
+ exit();
32
+ }
33
+ }
34
+ });
35
+ if (loading) {
36
+ return _jsx(Box, {
37
+ children: _jsx(Text, { children: "Loading MCP servers..." }),
38
+ });
39
+ }
40
+ if (result?.error) {
41
+ return _jsxs(Box, {
42
+ flexDirection: "column",
43
+ children: [
44
+ _jsx(Box, {
45
+ marginBottom: 1,
46
+ children: _jsx(Text, {
47
+ bold: true,
48
+ color: "red",
49
+ children: "\u274C Error loading MCP servers",
50
+ }),
51
+ }),
52
+ _jsx(Box, {
53
+ marginBottom: 1,
54
+ children: _jsx(Text, { children: result.error }),
55
+ }),
56
+ _jsx(Box, {
57
+ children: _jsx(Text, {
58
+ dimColor: true,
59
+ children: "Press Enter or Q to exit",
60
+ }),
61
+ }),
62
+ ],
63
+ });
64
+ }
65
+ if (!result || result.configs.length === 0) {
66
+ return _jsxs(Box, {
67
+ flexDirection: "column",
68
+ children: [
69
+ _jsx(Box, {
70
+ marginBottom: 1,
71
+ children: _jsx(Text, {
72
+ bold: true,
73
+ children: "No MCP servers configured",
74
+ }),
75
+ }),
76
+ _jsx(Box, {
77
+ marginBottom: 1,
78
+ children: _jsx(Text, {
79
+ dimColor: true,
80
+ children: "Add one with: town mcp add",
81
+ }),
82
+ }),
83
+ _jsx(Box, {
84
+ children: _jsx(Text, {
85
+ dimColor: true,
86
+ children: "Press Enter or Q to exit",
87
+ }),
88
+ }),
89
+ ],
90
+ });
91
+ }
92
+ return _jsxs(Box, {
93
+ flexDirection: "column",
94
+ children: [
95
+ _jsx(Box, {
96
+ marginBottom: 1,
97
+ children: _jsxs(Text, {
98
+ bold: true,
99
+ children: ["Configured MCP Servers (", result.configs.length, ")"],
100
+ }),
101
+ }),
102
+ result.configs.map((config, index) =>
103
+ _jsxs(
104
+ Box,
105
+ {
106
+ flexDirection: "column",
107
+ marginBottom: 1,
108
+ children: [
109
+ _jsx(Box, {
110
+ children: _jsxs(Text, {
111
+ bold: true,
112
+ color: "cyan",
113
+ children: [index + 1, ". ", config.name],
114
+ }),
115
+ }),
116
+ _jsx(Box, {
117
+ paddingLeft: 3,
118
+ children:
119
+ config.transport === "http"
120
+ ? _jsxs(Box, {
121
+ flexDirection: "column",
122
+ children: [
123
+ _jsxs(Text, {
124
+ children: [
125
+ "Transport: ",
126
+ _jsx(Text, { color: "green", children: "HTTP" }),
127
+ ],
128
+ }),
129
+ _jsxs(Text, { children: ["URL: ", config.url] }),
130
+ ],
131
+ })
132
+ : _jsxs(Box, {
133
+ flexDirection: "column",
134
+ children: [
135
+ _jsxs(Text, {
136
+ children: [
137
+ "Transport: ",
138
+ _jsx(Text, { color: "blue", children: "stdio" }),
139
+ ],
140
+ }),
141
+ _jsxs(Text, {
142
+ children: ["Command: ", config.command],
143
+ }),
144
+ config.args &&
145
+ config.args.length > 0 &&
146
+ _jsxs(Text, {
147
+ children: ["Args: ", config.args.join(" ")],
148
+ }),
149
+ ],
150
+ }),
151
+ }),
152
+ ],
153
+ },
154
+ config.name,
155
+ ),
156
+ ),
157
+ _jsx(Box, {
158
+ marginTop: 1,
159
+ children: _jsx(Text, {
160
+ dimColor: true,
161
+ children:
162
+ "Use `town mcp remove` to remove a server or `town mcp add` to add one",
163
+ }),
164
+ }),
165
+ _jsx(Box, {
166
+ marginTop: 1,
167
+ children: _jsx(Text, {
168
+ dimColor: true,
169
+ children: "Press Enter or Q to exit",
170
+ }),
171
+ }),
172
+ ],
173
+ });
174
+ }
175
+ // ============================================================================
176
+ // Export and Runner
177
+ // ============================================================================
178
+ export default MCPListApp;
179
+ export async function runMCPList() {
180
+ const { waitUntilExit, clear } = render(_jsx(MCPListApp, {}));
181
+ try {
182
+ await waitUntilExit();
183
+ } finally {
184
+ clear();
185
+ // Ensure cursor is visible
186
+ process.stdout.write("\x1B[?25h");
187
+ }
188
+ }
@@ -0,0 +1,3 @@
1
+ declare function MCPRemoveApp(): import("react/jsx-runtime").JSX.Element;
2
+ export default MCPRemoveApp;
3
+ export declare function runMCPRemove(): Promise<void>;
@@ -0,0 +1,208 @@
1
+ import { SingleSelect } from "@town/ui/tui";
2
+ import { Box, render, Text } from "ink";
3
+ import { useEffect, useState } from "react";
4
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
+ import {
6
+ deleteMCPConfig,
7
+ getMCPSummary,
8
+ listMCPConfigs,
9
+ } from "../lib/mcp-storage";
10
+
11
+ // ============================================================================
12
+ // Main Component
13
+ // ============================================================================
14
+ function MCPRemoveApp() {
15
+ const [stage, setStage] = useState("loading");
16
+ const [configs, setConfigs] = useState([]);
17
+ const [selectedName, setSelectedName] = useState(null);
18
+ const [selectedConfig, setSelectedConfig] = useState(null);
19
+ const [errorMessage, setErrorMessage] = useState("");
20
+ // Load configs on mount
21
+ useEffect(() => {
22
+ function loadConfigs() {
23
+ try {
24
+ const configList = listMCPConfigs();
25
+ setConfigs(configList);
26
+ setStage(configList.length > 0 ? "select" : "error");
27
+ if (configList.length === 0) {
28
+ setErrorMessage("No MCP servers configured");
29
+ }
30
+ } catch (error) {
31
+ const errorMsg = error instanceof Error ? error.message : String(error);
32
+ setErrorMessage(errorMsg);
33
+ setStage("error");
34
+ }
35
+ }
36
+ loadConfigs();
37
+ }, []);
38
+ // Handle removal
39
+ const handleRemove = () => {
40
+ if (!selectedName) return;
41
+ setStage("removing");
42
+ try {
43
+ const success = deleteMCPConfig(selectedName);
44
+ if (success) {
45
+ setStage("done");
46
+ // Exit immediately
47
+ process.exit(0);
48
+ } else {
49
+ throw new Error("Failed to delete MCP config");
50
+ }
51
+ } catch (error) {
52
+ const errorMsg = error instanceof Error ? error.message : String(error);
53
+ setErrorMessage(errorMsg);
54
+ setStage("error");
55
+ }
56
+ };
57
+ if (stage === "loading") {
58
+ return _jsx(Box, {
59
+ children: _jsx(Text, { children: "Loading MCP servers..." }),
60
+ });
61
+ }
62
+ if (stage === "error") {
63
+ return _jsxs(Box, {
64
+ flexDirection: "column",
65
+ children: [
66
+ _jsx(Box, {
67
+ marginBottom: 1,
68
+ children: _jsx(Text, {
69
+ bold: true,
70
+ color: "red",
71
+ children: "\u274C Error",
72
+ }),
73
+ }),
74
+ _jsx(Box, { children: _jsx(Text, { children: errorMessage }) }),
75
+ ],
76
+ });
77
+ }
78
+ if (stage === "select") {
79
+ const options = configs.map((config) => ({
80
+ label: config.name,
81
+ value: config.name,
82
+ description: getMCPSummary(config),
83
+ }));
84
+ return _jsxs(Box, {
85
+ flexDirection: "column",
86
+ children: [
87
+ _jsx(Box, {
88
+ marginBottom: 1,
89
+ children: _jsx(Text, {
90
+ bold: true,
91
+ children: "Select MCP server to remove",
92
+ }),
93
+ }),
94
+ _jsx(SingleSelect, {
95
+ options: options,
96
+ selected: selectedName,
97
+ onChange: setSelectedName,
98
+ onSubmit: (name) => {
99
+ const found = configs.find((c) => c.name === name);
100
+ if (found) {
101
+ setSelectedName(name);
102
+ setSelectedConfig(found);
103
+ setStage("confirm");
104
+ }
105
+ },
106
+ onCancel: () => process.exit(0),
107
+ }),
108
+ ],
109
+ });
110
+ }
111
+ if (stage === "confirm") {
112
+ return _jsxs(Box, {
113
+ flexDirection: "column",
114
+ children: [
115
+ _jsx(Box, {
116
+ marginBottom: 1,
117
+ children: _jsx(Text, { bold: true, children: "Confirm removal" }),
118
+ }),
119
+ _jsx(Box, {
120
+ marginBottom: 1,
121
+ children: _jsxs(Text, {
122
+ children: [
123
+ "Are you sure you want to remove:",
124
+ " ",
125
+ _jsx(Text, { bold: true, children: selectedConfig?.name }),
126
+ ],
127
+ }),
128
+ }),
129
+ _jsx(Box, {
130
+ marginBottom: 1,
131
+ children: _jsx(Text, {
132
+ dimColor: true,
133
+ children: getMCPSummary(selectedConfig),
134
+ }),
135
+ }),
136
+ _jsx(SingleSelect, {
137
+ options: [
138
+ {
139
+ label: "Yes, remove it",
140
+ value: "yes",
141
+ description: "Permanently delete this MCP server configuration",
142
+ },
143
+ {
144
+ label: "No, cancel",
145
+ value: "no",
146
+ description: "Go back to selection",
147
+ },
148
+ ],
149
+ selected: null,
150
+ onChange: () => {},
151
+ onSubmit: (choice) => {
152
+ if (choice === "yes") {
153
+ handleRemove();
154
+ } else {
155
+ setStage("select");
156
+ }
157
+ },
158
+ onCancel: () => setStage("select"),
159
+ }),
160
+ ],
161
+ });
162
+ }
163
+ if (stage === "removing") {
164
+ return _jsx(Box, {
165
+ children: _jsx(Text, {
166
+ children: "\uD83D\uDDD1\uFE0F Removing MCP server...",
167
+ }),
168
+ });
169
+ }
170
+ if (stage === "done") {
171
+ return _jsxs(Box, {
172
+ flexDirection: "column",
173
+ children: [
174
+ _jsx(Box, {
175
+ marginBottom: 1,
176
+ children: _jsx(Text, {
177
+ bold: true,
178
+ color: "green",
179
+ children: "\u2705 MCP server removed successfully",
180
+ }),
181
+ }),
182
+ _jsx(Box, {
183
+ children: _jsxs(Text, {
184
+ children: [
185
+ "Removed: ",
186
+ _jsx(Text, { bold: true, children: selectedConfig?.name }),
187
+ ],
188
+ }),
189
+ }),
190
+ ],
191
+ });
192
+ }
193
+ return _jsxs(Text, { children: ["Unknown stage: ", stage] });
194
+ }
195
+ // ============================================================================
196
+ // Export and Runner
197
+ // ============================================================================
198
+ export default MCPRemoveApp;
199
+ export async function runMCPRemove() {
200
+ const { waitUntilExit, clear } = render(_jsx(MCPRemoveApp, {}));
201
+ try {
202
+ await waitUntilExit();
203
+ } finally {
204
+ clear();
205
+ // Ensure cursor is visible
206
+ process.stdout.write("\x1B[?25h");
207
+ }
208
+ }
@@ -1,7 +1,7 @@
1
1
  export interface RunCommandOptions {
2
- name: string;
3
- http?: boolean;
4
- gui?: boolean;
5
- port?: number;
2
+ name: string;
3
+ http?: boolean;
4
+ gui?: boolean;
5
+ port?: number;
6
6
  }
7
7
  export declare function runCommand(options: RunCommandOptions): Promise<void>;
@@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { agentExists, getAgentPath } from "@townco/agent/storage";
6
+ import { agentExists, getAgentPath } from "@town/agent/storage";
7
+ import open from "open";
7
8
  async function loadEnvVars() {
8
9
  const envPath = join(homedir(), ".config", "town", ".env");
9
10
  const envVars = {};
@@ -23,7 +24,7 @@ async function loadEnvVars() {
23
24
  }
24
25
  }
25
26
  }
26
- catch (error) {
27
+ catch (_error) {
27
28
  console.warn(`Warning: Could not load environment variables from ${envPath}`);
28
29
  }
29
30
  return envVars;
@@ -97,6 +98,15 @@ export async function runCommand(options) {
97
98
  process.exit(code);
98
99
  }
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);
100
110
  }, 1000);
101
111
  agentProcess.on("close", (code) => {
102
112
  if (code !== 0 && code !== null) {
@@ -137,12 +147,8 @@ export async function runCommand(options) {
137
147
  }
138
148
  // Default: Start TUI interface with the agent
139
149
  console.log(`Starting interactive terminal for agent "${name}"...\n`);
140
- // Get path to TUI app from CLI package
141
- const { fileURLToPath } = await import("node:url");
142
- const { dirname } = await import("node:path");
143
- const currentFile = fileURLToPath(import.meta.url);
144
- const cliRoot = join(dirname(currentFile), "..", "..");
145
- const tuiPath = join(cliRoot, "tui", "index.js");
150
+ // Get path to TUI app
151
+ const tuiPath = join(process.cwd(), "apps", "tui", "src", "index.tsx");
146
152
  // Run TUI with the agent
147
153
  const tuiProcess = spawn("bun", [tuiPath, "--agent", binPath], {
148
154
  cwd: agentPath,
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { argument, command, constant, flag, multiple, object, option, optional,
3
3
  import { message } from "@optique/core/message";
4
4
  import { integer, string } from "@optique/core/valueparser";
5
5
  import { run } from "@optique/run";
6
- import { createSecret, deleteSecret, genenv, listSecrets } from "@townco/secret";
6
+ import { createSecret, deleteSecret, genenv, listSecrets } from "@town/secret";
7
7
  import inquirer from "inquirer";
8
8
  import { match } from "ts-pattern";
9
9
  import { configureCommand } from "./commands/configure.js";
@@ -11,6 +11,9 @@ import { createCommand } from "./commands/create.js";
11
11
  import { deleteCommand } from "./commands/delete.js";
12
12
  import { editCommand } from "./commands/edit.js";
13
13
  import { listCommand } from "./commands/list.js";
14
+ import { runMCPAdd } from "./commands/mcp-add.js";
15
+ import { runMCPList } from "./commands/mcp-list.js";
16
+ import { runMCPRemove } from "./commands/mcp-remove.js";
14
17
  import { runCommand } from "./commands/run.js";
15
18
  /**
16
19
  * Securely prompt for a secret value without echoing to the terminal
@@ -46,7 +49,20 @@ const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy
46
49
  }), { brief: message `Edit an agent.` }), command("delete", object({
47
50
  command: constant("delete"),
48
51
  name: argument(string({ metavar: "NAME" })),
49
- }), { brief: message `Delete an agent.` }), command("secret", object({
52
+ }), { brief: message `Delete an agent.` }), command("mcp", object({
53
+ command: constant("mcp"),
54
+ subcommand: or(command("add", object({
55
+ action: constant("add"),
56
+ name: optional(option("-n", "--name", string())),
57
+ url: optional(option("-u", "--url", string())),
58
+ command: optional(option("-c", "--command", string())),
59
+ args: multiple(option("-a", "--arg", string())),
60
+ }), { brief: message `Add a new MCP server.` }), command("list", constant("list"), {
61
+ brief: message `List all configured MCP servers.`,
62
+ }), command("remove", constant("remove"), {
63
+ brief: message `Remove an MCP server.`,
64
+ })),
65
+ }), { brief: message `Manage MCP (Model Context Protocol) servers.` }), command("secret", object({
50
66
  command: constant("secret"),
51
67
  subcommand: or(command("list", constant("list"), { brief: message `List secrets.` }), command("add", object({
52
68
  action: constant("add"),
@@ -104,6 +120,20 @@ async function main(parser, meta) {
104
120
  })
105
121
  .with({ command: "delete" }, async ({ name }) => {
106
122
  await deleteCommand(name);
123
+ })
124
+ .with({ command: "mcp" }, async ({ subcommand }) => {
125
+ await match(subcommand)
126
+ .with({ action: "add" }, async ({ name, url, command, args }) => {
127
+ await runMCPAdd({
128
+ ...(name !== undefined && { name }),
129
+ ...(url !== undefined && { url }),
130
+ ...(command !== undefined && { command }),
131
+ ...(args.length > 0 && { args }),
132
+ });
133
+ })
134
+ .with("list", async () => await runMCPList())
135
+ .with("remove", async () => await runMCPRemove())
136
+ .exhaustive();
107
137
  })
108
138
  .with({ command: "secret" }, async ({ subcommand }) => {
109
139
  await match(subcommand)
@@ -0,0 +1,31 @@
1
+ export type MCPConfig = {
2
+ name: string;
3
+ transport: "stdio" | "http";
4
+ command?: string;
5
+ args?: string[];
6
+ url?: string;
7
+ };
8
+ /**
9
+ * Save an MCP config to the store
10
+ */
11
+ export declare function saveMCPConfig(config: MCPConfig): void;
12
+ /**
13
+ * Load an MCP config by name
14
+ */
15
+ export declare function loadMCPConfig(name: string): MCPConfig | null;
16
+ /**
17
+ * Delete an MCP config by name
18
+ */
19
+ export declare function deleteMCPConfig(name: string): boolean;
20
+ /**
21
+ * List all MCP configs
22
+ */
23
+ export declare function listMCPConfigs(): MCPConfig[];
24
+ /**
25
+ * Check if an MCP config exists
26
+ */
27
+ export declare function mcpConfigExists(name: string): boolean;
28
+ /**
29
+ * Get a summary of an MCP config for display
30
+ */
31
+ export declare function getMCPSummary(config: MCPConfig): string;
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // ============================================================================
6
+ // Constants
7
+ // ============================================================================
8
+ const TOWN_CONFIG_DIR = join(homedir(), ".config", "town");
9
+ const MCPS_FILE = join(TOWN_CONFIG_DIR, "mcps.json");
10
+ // ============================================================================
11
+ // Helper Functions
12
+ // ============================================================================
13
+ /**
14
+ * Ensure the config directory exists
15
+ */
16
+ function ensureConfigDir() {
17
+ if (!existsSync(TOWN_CONFIG_DIR)) {
18
+ mkdirSync(TOWN_CONFIG_DIR, { recursive: true });
19
+ }
20
+ }
21
+ /**
22
+ * Load all MCP configs from the JSON file
23
+ */
24
+ function loadStore() {
25
+ ensureConfigDir();
26
+ if (!existsSync(MCPS_FILE)) {
27
+ return {};
28
+ }
29
+ try {
30
+ const content = readFileSync(MCPS_FILE, "utf-8");
31
+ return JSON.parse(content);
32
+ } catch (error) {
33
+ throw new Error(
34
+ `Failed to load MCP configs: ${error instanceof Error ? error.message : String(error)}`,
35
+ );
36
+ }
37
+ }
38
+ /**
39
+ * Save all MCP configs to the JSON file
40
+ */
41
+ function saveStore(store) {
42
+ ensureConfigDir();
43
+ try {
44
+ const content = JSON.stringify(store, null, 2);
45
+ writeFileSync(MCPS_FILE, content, "utf-8");
46
+ } catch (error) {
47
+ throw new Error(
48
+ `Failed to save MCP configs: ${error instanceof Error ? error.message : String(error)}`,
49
+ );
50
+ }
51
+ }
52
+ // ============================================================================
53
+ // Public API
54
+ // ============================================================================
55
+ /**
56
+ * Save an MCP config to the store
57
+ */
58
+ export function saveMCPConfig(config) {
59
+ const store = loadStore();
60
+ store[config.name] = config;
61
+ saveStore(store);
62
+ }
63
+ /**
64
+ * Load an MCP config by name
65
+ */
66
+ export function loadMCPConfig(name) {
67
+ const store = loadStore();
68
+ return store[name] || null;
69
+ }
70
+ /**
71
+ * Delete an MCP config by name
72
+ */
73
+ export function deleteMCPConfig(name) {
74
+ const store = loadStore();
75
+ if (store[name]) {
76
+ delete store[name];
77
+ saveStore(store);
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * List all MCP configs
84
+ */
85
+ export function listMCPConfigs() {
86
+ const store = loadStore();
87
+ return Object.values(store).sort((a, b) => a.name.localeCompare(b.name));
88
+ }
89
+ /**
90
+ * Check if an MCP config exists
91
+ */
92
+ export function mcpConfigExists(name) {
93
+ const store = loadStore();
94
+ return name in store;
95
+ }
96
+ /**
97
+ * Get a summary of an MCP config for display
98
+ */
99
+ export function getMCPSummary(config) {
100
+ if (config.transport === "http") {
101
+ return `HTTP: ${config.url}`;
102
+ } else {
103
+ const parts = [`Stdio: ${config.command}`];
104
+ if (config.args && config.args.length > 0) {
105
+ parts.push(`[${config.args.join(" ")}]`);
106
+ }
107
+ return parts.join(" ");
108
+ }
109
+ }