@townco/cli 0.1.12 → 0.1.14
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/dist/commands/create.d.ts +5 -6
- package/dist/commands/create.js +19 -94
- package/dist/commands/mcp-add.d.ts +10 -5
- package/dist/commands/mcp-list.js +174 -47
- package/dist/commands/run.js +72 -54
- package/dist/commands/tool-add.js +2 -29
- package/dist/components/ProcessPane.d.ts +7 -0
- package/dist/components/ProcessPane.js +17 -0
- package/dist/components/StatusLine.d.ts +5 -0
- package/dist/components/StatusLine.js +5 -0
- package/dist/components/TabbedOutput.d.ts +12 -0
- package/dist/components/TabbedOutput.js +136 -0
- package/dist/lib/editor-utils.d.ts +15 -0
- package/dist/lib/editor-utils.js +112 -0
- package/dist/lib/mcp-storage.d.ts +5 -5
- package/dist/lib/mcp-storage.js +50 -48
- package/dist/lib/port-utils.d.ts +8 -0
- package/dist/lib/port-utils.js +35 -0
- package/package.json +5 -5
- package/dist/commands/tool-stub.d.ts +0 -6
- package/dist/commands/tool-stub.js +0 -376
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
2
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
@@ -8,6 +7,7 @@ import { MultiSelect } from "@townco/ui/tui";
|
|
|
8
7
|
import { Box, render, Text, useApp, useInput } from "ink";
|
|
9
8
|
import TextInput from "ink-text-input";
|
|
10
9
|
import { useEffect, useState } from "react";
|
|
10
|
+
import { openInEditor } from "../lib/editor-utils";
|
|
11
11
|
import { ensureToolsDir, getToolsDir, saveToolConfig, toolConfigExists, } from "../lib/tool-storage";
|
|
12
12
|
// ============================================================================
|
|
13
13
|
// Helper Functions
|
|
@@ -65,33 +65,6 @@ export default function ${camelCaseName}(_input: unknown) {
|
|
|
65
65
|
}
|
|
66
66
|
`;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Open a file in the user's editor and wait for it to close
|
|
70
|
-
*/
|
|
71
|
-
async function openInEditor(filePath) {
|
|
72
|
-
return new Promise((resolve, reject) => {
|
|
73
|
-
// Get editor from environment, fallback to vi
|
|
74
|
-
const editor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
75
|
-
// Parse editor command (might include args like "code --wait")
|
|
76
|
-
const editorParts = editor.split(" ");
|
|
77
|
-
const editorCommand = editorParts[0] || "vi";
|
|
78
|
-
const editorArgs = [...editorParts.slice(1), filePath];
|
|
79
|
-
const child = spawn(editorCommand, editorArgs, {
|
|
80
|
-
stdio: "inherit",
|
|
81
|
-
});
|
|
82
|
-
child.on("exit", (code) => {
|
|
83
|
-
if (code === 0 || code === null) {
|
|
84
|
-
resolve();
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
reject(new Error(`Editor exited with code ${code}`));
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
child.on("error", (error) => {
|
|
91
|
-
reject(error);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
68
|
function TextInputStage({ title, value, onChange, onSubmit, onCancel, placeholder, }) {
|
|
96
69
|
useInput((_input, key) => {
|
|
97
70
|
if (key.escape) {
|
|
@@ -211,7 +184,7 @@ function ToolAddApp({ name: initialName }) {
|
|
|
211
184
|
const stubContent = generateStubTemplate(initialName);
|
|
212
185
|
writeFileSync(tempFile, stubContent, "utf-8");
|
|
213
186
|
// Open in editor
|
|
214
|
-
await openInEditor(tempFile);
|
|
187
|
+
await openInEditor({ filePath: tempFile });
|
|
215
188
|
// After editor closes, move to name input stage
|
|
216
189
|
setEditorOpened(true);
|
|
217
190
|
}
|
|
@@ -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
|
+
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified editor opening function that handles both content and file modes
|
|
3
|
+
*
|
|
4
|
+
* @example Content mode
|
|
5
|
+
* const edited = await openInEditor("Hello world", { extension: '.txt' });
|
|
6
|
+
* if (edited) console.log("User edited:", edited);
|
|
7
|
+
*
|
|
8
|
+
* @example File mode
|
|
9
|
+
* await openInEditor({ filePath: '/path/to/file.ts' });
|
|
10
|
+
*/
|
|
11
|
+
export declare function openInEditor(contentOrOptions: string | {
|
|
12
|
+
filePath: string;
|
|
13
|
+
}, options?: {
|
|
14
|
+
extension?: string;
|
|
15
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Standardized editor fallback chain
|
|
7
|
+
*/
|
|
8
|
+
const EDITOR_FALLBACKS = [
|
|
9
|
+
() => process.env.EDITOR,
|
|
10
|
+
() => process.env.VISUAL,
|
|
11
|
+
() => "code",
|
|
12
|
+
() => "vi",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Parse an editor command that might include arguments
|
|
16
|
+
* e.g., "code --wait" -> ["code", "--wait"]
|
|
17
|
+
*/
|
|
18
|
+
function parseEditorCommand(editor) {
|
|
19
|
+
const parts = editor.trim().split(/\s+/);
|
|
20
|
+
const command = parts[0] || "vi";
|
|
21
|
+
const args = parts.slice(1);
|
|
22
|
+
return [command, args];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Attempt to spawn an editor and wait for it to close
|
|
26
|
+
*/
|
|
27
|
+
async function spawnEditor(editor, filePath, additionalArgs = []) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const [command, editorArgs] = parseEditorCommand(editor);
|
|
30
|
+
// Special handling for 'code' - add --wait if not present
|
|
31
|
+
const args = command === "code" && !editorArgs.includes("--wait")
|
|
32
|
+
? ["--wait", ...editorArgs, ...additionalArgs, filePath]
|
|
33
|
+
: [...editorArgs, ...additionalArgs, filePath];
|
|
34
|
+
const child = spawn(command, args, {
|
|
35
|
+
stdio: "inherit",
|
|
36
|
+
});
|
|
37
|
+
child.on("close", (code) => {
|
|
38
|
+
resolve(code === 0 || code === null);
|
|
39
|
+
});
|
|
40
|
+
child.on("error", () => {
|
|
41
|
+
resolve(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Try editors in the fallback chain until one succeeds
|
|
47
|
+
*/
|
|
48
|
+
async function tryEditorsInOrder(filePath) {
|
|
49
|
+
for (const getEditor of EDITOR_FALLBACKS) {
|
|
50
|
+
const editor = getEditor();
|
|
51
|
+
if (editor) {
|
|
52
|
+
const success = await spawnEditor(editor, filePath);
|
|
53
|
+
if (success) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Unified editor opening function that handles both content and file modes
|
|
62
|
+
*
|
|
63
|
+
* @example Content mode
|
|
64
|
+
* const edited = await openInEditor("Hello world", { extension: '.txt' });
|
|
65
|
+
* if (edited) console.log("User edited:", edited);
|
|
66
|
+
*
|
|
67
|
+
* @example File mode
|
|
68
|
+
* await openInEditor({ filePath: '/path/to/file.ts' });
|
|
69
|
+
*/
|
|
70
|
+
export async function openInEditor(contentOrOptions, options) {
|
|
71
|
+
const isContentMode = typeof contentOrOptions === "string";
|
|
72
|
+
if (isContentMode) {
|
|
73
|
+
// Content mode: create temp file, edit, return content
|
|
74
|
+
const content = contentOrOptions;
|
|
75
|
+
const extension = options?.extension || ".txt";
|
|
76
|
+
const tempFile = join(tmpdir(), `editor-${Date.now()}${extension}`);
|
|
77
|
+
try {
|
|
78
|
+
// Write initial content
|
|
79
|
+
writeFileSync(tempFile, content, "utf-8");
|
|
80
|
+
// Try editors in fallback order
|
|
81
|
+
const success = await tryEditorsInOrder(tempFile);
|
|
82
|
+
if (success) {
|
|
83
|
+
// Read edited content
|
|
84
|
+
const editedContent = readFileSync(tempFile, "utf-8");
|
|
85
|
+
unlinkSync(tempFile);
|
|
86
|
+
return editedContent;
|
|
87
|
+
}
|
|
88
|
+
// All editors failed
|
|
89
|
+
unlinkSync(tempFile);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
catch (_error) {
|
|
93
|
+
// Clean up temp file if it exists
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(tempFile);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Ignore cleanup errors
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// File mode: edit existing file, return void
|
|
105
|
+
const { filePath } = contentOrOptions;
|
|
106
|
+
const success = await tryEditorsInOrder(filePath);
|
|
107
|
+
if (!success) {
|
|
108
|
+
throw new Error("Failed to open editor");
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type MCPConfig = {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
name: string;
|
|
3
|
+
transport: "stdio" | "http";
|
|
4
|
+
command?: string;
|
|
5
|
+
args?: string[];
|
|
6
|
+
url?: string;
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
9
|
* Save an MCP config to the store
|
package/dist/lib/mcp-storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
|
|
4
5
|
// ============================================================================
|
|
5
6
|
// Constants
|
|
6
7
|
// ============================================================================
|
|
@@ -13,38 +14,40 @@ const MCPS_FILE = join(TOWN_CONFIG_DIR, "mcps.json");
|
|
|
13
14
|
* Ensure the config directory exists
|
|
14
15
|
*/
|
|
15
16
|
function ensureConfigDir() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
if (!existsSync(TOWN_CONFIG_DIR)) {
|
|
18
|
+
mkdirSync(TOWN_CONFIG_DIR, { recursive: true });
|
|
19
|
+
}
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
22
|
* Load all MCP configs from the JSON file
|
|
22
23
|
*/
|
|
23
24
|
function loadStore() {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
}
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
39
|
* Save all MCP configs to the JSON file
|
|
38
40
|
*/
|
|
39
41
|
function saveStore(store) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
51
|
}
|
|
49
52
|
// ============================================================================
|
|
50
53
|
// Public API
|
|
@@ -53,55 +56,54 @@ function saveStore(store) {
|
|
|
53
56
|
* Save an MCP config to the store
|
|
54
57
|
*/
|
|
55
58
|
export function saveMCPConfig(config) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
const store = loadStore();
|
|
60
|
+
store[config.name] = config;
|
|
61
|
+
saveStore(store);
|
|
59
62
|
}
|
|
60
63
|
/**
|
|
61
64
|
* Load an MCP config by name
|
|
62
65
|
*/
|
|
63
66
|
export function loadMCPConfig(name) {
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const store = loadStore();
|
|
68
|
+
return store[name] || null;
|
|
66
69
|
}
|
|
67
70
|
/**
|
|
68
71
|
* Delete an MCP config by name
|
|
69
72
|
*/
|
|
70
73
|
export function deleteMCPConfig(name) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
const store = loadStore();
|
|
75
|
+
if (store[name]) {
|
|
76
|
+
delete store[name];
|
|
77
|
+
saveStore(store);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
78
81
|
}
|
|
79
82
|
/**
|
|
80
83
|
* List all MCP configs
|
|
81
84
|
*/
|
|
82
85
|
export function listMCPConfigs() {
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
const store = loadStore();
|
|
87
|
+
return Object.values(store).sort((a, b) => a.name.localeCompare(b.name));
|
|
85
88
|
}
|
|
86
89
|
/**
|
|
87
90
|
* Check if an MCP config exists
|
|
88
91
|
*/
|
|
89
92
|
export function mcpConfigExists(name) {
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
const store = loadStore();
|
|
94
|
+
return name in store;
|
|
92
95
|
}
|
|
93
96
|
/**
|
|
94
97
|
* Get a summary of an MCP config for display
|
|
95
98
|
*/
|
|
96
99
|
export function getMCPSummary(config) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
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
|
+
}
|
|
107
109
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.14",
|
|
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.
|
|
22
|
+
"@townco/tsconfig": "0.1.6",
|
|
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.
|
|
30
|
-
"@townco/secret": "0.1.
|
|
31
|
-
"@townco/ui": "0.1.
|
|
29
|
+
"@townco/agent": "0.1.14",
|
|
30
|
+
"@townco/secret": "0.1.9",
|
|
31
|
+
"@townco/ui": "0.1.9",
|
|
32
32
|
"@types/inquirer": "^9.0.9",
|
|
33
33
|
"ink": "^6.4.0",
|
|
34
34
|
"ink-text-input": "^6.0.0",
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
interface ToolStubProps {
|
|
2
|
-
name?: string;
|
|
3
|
-
}
|
|
4
|
-
declare function ToolStubApp({ name: initialName }: ToolStubProps): import("react/jsx-runtime").JSX.Element | null;
|
|
5
|
-
export default ToolStubApp;
|
|
6
|
-
export declare function runToolStub(props?: ToolStubProps): Promise<void>;
|