@townco/cli 0.1.12 → 0.1.13

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,13 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { spawn } from "node:child_process";
3
- import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
2
  import { scaffoldAgent } from "@townco/agent/scaffold";
7
3
  import { InputBox, MultiSelect, SingleSelect } from "@townco/ui/tui";
8
4
  import { Box, render, Text, useInput } from "ink";
9
5
  import TextInput from "ink-text-input";
10
6
  import { useEffect, useState } from "react";
7
+ import { openInEditor } from "../lib/editor-utils";
11
8
  const AVAILABLE_MODELS = [
12
9
  {
13
10
  label: "Claude Sonnet 4.5",
@@ -55,70 +52,6 @@ function NameInput({ nameInput, setNameInput, onSubmit }) {
55
52
  });
56
53
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Enter agent name:" }) }), _jsxs(Box, { children: [_jsxs(Text, { children: [">", " "] }), _jsx(TextInput, { value: nameInput, onChange: setNameInput, onSubmit: onSubmit })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: Continue \u2022 Esc: Cancel" }) })] }));
57
54
  }
58
- async function openEditor(initialContent) {
59
- const tempFile = join(tmpdir(), `agent-prompt-${Date.now()}.txt`);
60
- try {
61
- // Write initial content
62
- writeFileSync(tempFile, initialContent, "utf-8");
63
- // Try $EDITOR first
64
- const editor = process.env.EDITOR;
65
- if (editor) {
66
- try {
67
- await new Promise((resolve, reject) => {
68
- const child = spawn(editor, [tempFile], {
69
- stdio: "inherit",
70
- });
71
- child.on("close", (code) => {
72
- if (code === 0)
73
- resolve();
74
- else
75
- reject(new Error(`Editor exited with code ${code}`));
76
- });
77
- child.on("error", reject);
78
- });
79
- const content = readFileSync(tempFile, "utf-8");
80
- unlinkSync(tempFile);
81
- return content;
82
- }
83
- catch (_error) {
84
- // Fall through to try 'code'
85
- }
86
- }
87
- // Try 'code' (VS Code) as fallback
88
- try {
89
- await new Promise((resolve, reject) => {
90
- const child = spawn("code", ["--wait", tempFile], {
91
- stdio: "inherit",
92
- });
93
- child.on("close", (code) => {
94
- if (code === 0)
95
- resolve();
96
- else
97
- reject(new Error(`Code exited with code ${code}`));
98
- });
99
- child.on("error", reject);
100
- });
101
- const content = readFileSync(tempFile, "utf-8");
102
- unlinkSync(tempFile);
103
- return content;
104
- }
105
- catch (_error) {
106
- // Clean up and return null to signal fallback to inline
107
- unlinkSync(tempFile);
108
- return null;
109
- }
110
- }
111
- catch (_error) {
112
- // Clean up temp file if it exists
113
- try {
114
- unlinkSync(tempFile);
115
- }
116
- catch {
117
- // Ignore cleanup errors
118
- }
119
- return null;
120
- }
121
- }
122
55
  function CreateApp({ name: initialName, model: initialModel, tools: initialTools, systemPrompt: initialSystemPrompt, overwrite = false, }) {
123
56
  // Determine the starting stage based on what's provided
124
57
  const determineInitialStage = () => {
@@ -150,13 +83,14 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
150
83
  const [agentPath, setAgentPath] = useState(null);
151
84
  // Handle opening editor when systemPrompt stage is entered from review
152
85
  useEffect(() => {
153
- if (stage === "systemPrompt" &&
154
- isEditingFromReview &&
155
- !isEditingPrompt &&
156
- promptEditMode === null) {
157
- // Trigger editor opening
158
- setIsEditingPrompt(true);
159
- openEditor(agentDef.systemPrompt || "You are a helpful assistant.").then((editorContent) => {
86
+ (async () => {
87
+ if (stage === "systemPrompt" &&
88
+ isEditingFromReview &&
89
+ !isEditingPrompt &&
90
+ promptEditMode === null) {
91
+ // Trigger editor opening
92
+ setIsEditingPrompt(true);
93
+ const editorContent = await openInEditor(agentDef.systemPrompt || "You are a helpful assistant.");
160
94
  if (editorContent !== null) {
161
95
  // Editor worked
162
96
  setAgentDef({ ...agentDef, systemPrompt: editorContent });
@@ -170,8 +104,8 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
170
104
  setIsEditingPrompt(false);
171
105
  setSystemPromptInput(agentDef.systemPrompt || "You are a helpful assistant.");
172
106
  }
173
- });
174
- }
107
+ }
108
+ })();
175
109
  }, [stage, isEditingFromReview, isEditingPrompt, promptEditMode, agentDef]);
176
110
  // Handle scaffolding when entering "done" stage
177
111
  useEffect(() => {
@@ -271,7 +205,7 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
271
205
  setStage("systemPrompt");
272
206
  // Attempt to open editor
273
207
  setIsEditingPrompt(true);
274
- const editorContent = await openEditor(agentDef.systemPrompt || "You are a helpful assistant.");
208
+ const editorContent = await openInEditor(agentDef.systemPrompt || "You are a helpful assistant.");
275
209
  if (editorContent !== null) {
276
210
  // Editor worked
277
211
  setAgentDef({ ...agentDef, systemPrompt: editorContent });
@@ -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
  }
package/dist/index.js CHANGED
File without changes
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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.4",
22
+ "@townco/tsconfig": "0.1.5",
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.12",
30
- "@townco/secret": "0.1.7",
31
- "@townco/ui": "0.1.7",
29
+ "@townco/agent": "0.1.13",
30
+ "@townco/secret": "0.1.8",
31
+ "@townco/ui": "0.1.8",
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>;
@@ -1,376 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { spawn } from "node:child_process";
3
- import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { getAgentPath, listAgents } from "@townco/agent/storage";
7
- import { MultiSelect } from "@townco/ui/tui";
8
- import { Box, render, Text, useApp, useInput } from "ink";
9
- import TextInput from "ink-text-input";
10
- import { useEffect, useState } from "react";
11
- import { ensureToolsDir, getToolsDir, saveToolConfig, toolConfigExists, } from "../lib/tool-storage";
12
- // ============================================================================
13
- // Helper Functions
14
- // ============================================================================
15
- /**
16
- * Normalize a tool name to kebab-case for use as a filename
17
- */
18
- function normalizeToolName(name) {
19
- return name
20
- .trim()
21
- .toLowerCase()
22
- .replace(/[^a-z0-9]+/g, "-")
23
- .replace(/^-+|-+$/g, "");
24
- }
25
- /**
26
- * Convert kebab-case or snake_case to camelCase for function names
27
- */
28
- function toCamelCase(str) {
29
- return str.replace(/[-_](.)/g, (_, char) => char.toUpperCase());
30
- }
31
- /**
32
- * Convert a name to snake_case for the tool name export
33
- */
34
- function toSnakeCase(str) {
35
- return str
36
- .trim()
37
- .toLowerCase()
38
- .replace(/[^a-z0-9]+/g, "_")
39
- .replace(/^_+|_+$/g, "");
40
- }
41
- /**
42
- * Generate the stub template content
43
- */
44
- function generateStubTemplate(toolName) {
45
- const snakeCaseName = toolName ? toSnakeCase(toolName) : "tool_name";
46
- const camelCaseName = toolName ? toCamelCase(toolName) : "toolName";
47
- const description = toolName
48
- ? `Describe what ${toolName} does here`
49
- : "Describe what your tool does here";
50
- return `// biome-ignore lint/suspicious/noExplicitAny: .
51
- export const schema = (z: any) =>
52
- z.object({
53
- // Define your input parameters here
54
- // Example: message: z.string().describe("The message to process"),
55
- input: z.string().optional(),
56
- });
57
-
58
- export const name = "${snakeCaseName}";
59
- export const description = "${description}";
60
-
61
- export default function ${camelCaseName}(_input: unknown) {
62
- // TODO: Implement your tool logic here
63
-
64
- return "result";
65
- }
66
- `;
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
- function TextInputStage({ title, value, onChange, onSubmit, onCancel, placeholder, }) {
96
- useInput((_input, key) => {
97
- if (key.escape) {
98
- onCancel();
99
- }
100
- });
101
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: title }) }), _jsxs(Box, { children: [_jsxs(Text, { children: [">", " "] }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, ...(placeholder && { placeholder }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: Continue \u2022 Esc: Back" }) })] }));
102
- }
103
- function NameInputStage({ value, onChange, onNext, onBack, }) {
104
- const [localError, setLocalError] = useState(null);
105
- const handleSubmit = () => {
106
- const trimmed = value.trim();
107
- if (!trimmed) {
108
- return;
109
- }
110
- // Check for duplicate
111
- if (toolConfigExists(trimmed)) {
112
- setLocalError(`Tool "${trimmed}" already exists`);
113
- return;
114
- }
115
- onNext(trimmed);
116
- };
117
- const handleChange = (newValue) => {
118
- if (localError) {
119
- setLocalError(null);
120
- }
121
- onChange(newValue);
122
- };
123
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextInputStage, { title: "Enter tool name:", value: value, onChange: handleChange, onSubmit: handleSubmit, onCancel: onBack, placeholder: "my-custom-tool" }), localError && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["\u274C ", localError] }) }))] }));
124
- }
125
- function NoAgentsMessage({ onNext }) {
126
- useEffect(() => {
127
- const timer = setTimeout(() => {
128
- onNext();
129
- }, 1000);
130
- return () => clearTimeout(timer);
131
- }, [onNext]);
132
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "No agents found. Tool will be saved globally." }), _jsx(Text, { dimColor: true, children: "You can attach it to agents later." })] }));
133
- }
134
- function AgentSelectionStage({ selectedAgents, onSelectedAgentsChange, onNext, onBack, }) {
135
- const [availableAgents, setAvailableAgents] = useState([]);
136
- const [isLoading, setIsLoading] = useState(true);
137
- useEffect(() => {
138
- // Fetch available agents
139
- const fetchAgents = async () => {
140
- try {
141
- const agents = await listAgents();
142
- setAvailableAgents(agents);
143
- }
144
- catch (error) {
145
- console.error("Error fetching agents:", error);
146
- setAvailableAgents([]);
147
- }
148
- finally {
149
- setIsLoading(false);
150
- }
151
- };
152
- fetchAgents();
153
- }, []);
154
- // If still loading, show loading message
155
- if (isLoading) {
156
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "Loading agents..." }) }));
157
- }
158
- // If no agents available, show info message and auto-proceed
159
- if (availableAgents.length === 0) {
160
- return _jsx(NoAgentsMessage, { onNext: onNext });
161
- }
162
- // Show agent selection UI
163
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Register tool with agents (optional):" }) }), _jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Select which agents should have access to this tool." }), _jsx(Text, { dimColor: true, children: "You can skip this step and register agents later." })] }), _jsx(MultiSelect, { options: availableAgents.map((agent) => ({
164
- label: agent,
165
- value: agent,
166
- })), selected: selectedAgents, onChange: onSelectedAgentsChange, onSubmit: onNext, onCancel: onBack })] }));
167
- }
168
- function DoneStage({ config, status, error, attachedAgents }) {
169
- const { exit } = useApp();
170
- useEffect(() => {
171
- if (status === "done") {
172
- setImmediate(() => {
173
- exit();
174
- });
175
- }
176
- }, [status, exit]);
177
- if (status === "saving") {
178
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "\u23F3 Saving tool configuration..." }) }));
179
- }
180
- if (status === "error") {
181
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "\u274C Error saving tool" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: error }) })] }));
182
- }
183
- if (status === "done") {
184
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", children: "\u2705 Tool saved successfully!" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Name: ", config.name] }), _jsxs(Text, { dimColor: true, children: ["Path: ", config.path] })] }), attachedAgents.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: ["Registered with agents: ", attachedAgents.join(", ")] }) }))] }));
185
- }
186
- return null;
187
- }
188
- // ============================================================================
189
- // Main Component
190
- // ============================================================================
191
- function ToolStubApp({ name: initialName }) {
192
- const { exit } = useApp();
193
- const [stage, setStage] = useState("name");
194
- const [config, setConfig] = useState({});
195
- const [nameInput, setNameInput] = useState(initialName || "");
196
- const [saveStatus, setSaveStatus] = useState("pending");
197
- const [saveError, setSaveError] = useState(null);
198
- const [selectedAgents, setSelectedAgents] = useState([]);
199
- const [tempFilePath, setTempFilePath] = useState(null);
200
- const [editorOpened, setEditorOpened] = useState(false);
201
- // On mount, create temp file and open editor
202
- useEffect(() => {
203
- if (editorOpened)
204
- return;
205
- const setupAndOpenEditor = async () => {
206
- try {
207
- // Generate temp file path
208
- const tempFile = join(tmpdir(), `town-tool-${Date.now()}.ts`);
209
- setTempFilePath(tempFile);
210
- // Generate and write stub template
211
- const stubContent = generateStubTemplate(initialName);
212
- writeFileSync(tempFile, stubContent, "utf-8");
213
- // Open in editor
214
- await openInEditor(tempFile);
215
- // After editor closes, move to name input stage
216
- setEditorOpened(true);
217
- }
218
- catch (error) {
219
- setSaveStatus("error");
220
- setSaveError(`Failed to open editor: ${error instanceof Error ? error.message : String(error)}`);
221
- setStage("done");
222
- }
223
- };
224
- setupAndOpenEditor();
225
- }, [initialName, editorOpened]);
226
- const handleSave = (toolName, agentsToAttach) => {
227
- setSaveStatus("saving");
228
- try {
229
- if (!tempFilePath) {
230
- setSaveStatus("error");
231
- setSaveError("Temp file path not found");
232
- return;
233
- }
234
- // Check if temp file still exists
235
- if (!existsSync(tempFilePath)) {
236
- setSaveStatus("error");
237
- setSaveError("Temp file was deleted");
238
- return;
239
- }
240
- // Read the edited content
241
- const editedContent = readFileSync(tempFilePath, "utf-8");
242
- // Ensure tools directory exists
243
- ensureToolsDir();
244
- // Normalize the tool name for the filename
245
- const normalizedName = normalizeToolName(toolName);
246
- const toolsDir = getToolsDir();
247
- const targetPath = join(toolsDir, `${normalizedName}.ts`);
248
- // Write the edited content to the target path
249
- try {
250
- writeFileSync(targetPath, editedContent, "utf-8");
251
- }
252
- catch (error) {
253
- setSaveStatus("error");
254
- setSaveError(`Failed to save file: ${error instanceof Error ? error.message : String(error)}`);
255
- return;
256
- }
257
- // Create tool config
258
- const toolConfig = {
259
- name: toolName,
260
- path: targetPath,
261
- };
262
- // Save to global tool storage
263
- saveToolConfig(toolConfig);
264
- // Update selected agents
265
- for (const agentName of agentsToAttach) {
266
- try {
267
- const agentPath = getAgentPath(agentName);
268
- const agentJsonPath = join(agentPath, "agent.json");
269
- // Read existing agent.json
270
- const agentJsonContent = readFileSync(agentJsonPath, "utf-8");
271
- const agentDef = JSON.parse(agentJsonContent);
272
- // Add tool to tools array (create array if doesn't exist)
273
- if (!agentDef.tools) {
274
- agentDef.tools = [];
275
- }
276
- // Create custom tool object
277
- const customTool = {
278
- type: "custom",
279
- modulePath: targetPath,
280
- };
281
- // Check if this tool is already in the agent's config
282
- const existingIndex = agentDef.tools.findIndex((tool) => typeof tool === "object" &&
283
- tool !== null &&
284
- "type" in tool &&
285
- tool.type === "custom" &&
286
- "modulePath" in tool &&
287
- tool.modulePath === targetPath);
288
- if (existingIndex >= 0) {
289
- // Update existing config
290
- agentDef.tools[existingIndex] = customTool;
291
- }
292
- else {
293
- // Add new config
294
- agentDef.tools.push(customTool);
295
- }
296
- // Write back to agent.json
297
- writeFileSync(agentJsonPath, JSON.stringify(agentDef, null, 2));
298
- }
299
- catch (error) {
300
- console.error(`Error updating agent ${agentName}:`, error);
301
- // Continue with other agents even if one fails
302
- }
303
- }
304
- // Clean up temp file
305
- try {
306
- unlinkSync(tempFilePath);
307
- }
308
- catch (error) {
309
- // Ignore cleanup errors
310
- }
311
- setConfig(toolConfig);
312
- setSaveStatus("done");
313
- }
314
- catch (error) {
315
- setSaveStatus("error");
316
- setSaveError(error instanceof Error ? error.message : "Unknown error occurred");
317
- }
318
- };
319
- // Wait for editor to open and close before showing UI
320
- if (!editorOpened) {
321
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "Opening editor..." }) }));
322
- }
323
- // If there was an error during editor setup, show done stage with error
324
- if (saveStatus === "error" && stage === "done") {
325
- return (_jsx(DoneStage, { config: config, status: saveStatus, error: saveError, attachedAgents: selectedAgents }));
326
- }
327
- // Name input stage
328
- if (stage === "name") {
329
- return (_jsx(NameInputStage, { value: nameInput, onChange: setNameInput, onNext: (name) => {
330
- setConfig({ ...config, name });
331
- setStage("agentSelection");
332
- }, onBack: () => {
333
- // Clean up temp file
334
- if (tempFilePath) {
335
- try {
336
- unlinkSync(tempFilePath);
337
- }
338
- catch (error) {
339
- // Ignore cleanup errors
340
- }
341
- }
342
- exit();
343
- } }));
344
- }
345
- // Agent selection stage
346
- if (stage === "agentSelection") {
347
- return (_jsx(AgentSelectionStage, { selectedAgents: selectedAgents, onSelectedAgentsChange: setSelectedAgents, onNext: () => {
348
- if (!config.name) {
349
- setSaveStatus("error");
350
- setSaveError("Tool name is required");
351
- setStage("done");
352
- return;
353
- }
354
- handleSave(config.name, selectedAgents);
355
- setStage("done");
356
- }, onBack: () => setStage("name") }));
357
- }
358
- // Done stage
359
- if (stage === "done") {
360
- return (_jsx(DoneStage, { config: config, status: saveStatus, error: saveError, attachedAgents: selectedAgents }));
361
- }
362
- return null;
363
- }
364
- // ============================================================================
365
- // Export and Runner
366
- // ============================================================================
367
- export default ToolStubApp;
368
- export async function runToolStub(props = {}) {
369
- // Set stdin to raw mode to capture input
370
- if (process.stdin.isTTY) {
371
- process.stdin.setRawMode(true);
372
- }
373
- const { waitUntilExit } = render(_jsx(ToolStubApp, { ...props }));
374
- // Wait for the app to exit before returning
375
- await waitUntilExit();
376
- }