@townco/cli 0.1.0

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,9 @@
1
+ interface CreateCommandProps {
2
+ name?: string;
3
+ model?: string;
4
+ tools?: readonly string[];
5
+ systemPrompt?: string;
6
+ overwrite?: boolean;
7
+ }
8
+ export declare function createCommand(props: CreateCommandProps): Promise<void>;
9
+ export {};
@@ -0,0 +1,385 @@
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
+ import { scaffoldAgent } from "@townco/agent/scaffold";
7
+ import { InputBox, MultiSelect, SingleSelect } from "@townco/ui/tui";
8
+ import { Box, render, Text, useInput } from "ink";
9
+ import TextInput from "ink-text-input";
10
+ import { useEffect, useState } from "react";
11
+ const AVAILABLE_MODELS = [
12
+ {
13
+ label: "Claude Sonnet 4.5",
14
+ value: "claude-sonnet-4-5-20250929",
15
+ description: "Latest Sonnet model - balanced performance and speed",
16
+ },
17
+ {
18
+ label: "Claude Sonnet 4",
19
+ value: "claude-sonnet-4-20250514",
20
+ description: "Previous Sonnet version",
21
+ },
22
+ {
23
+ label: "Claude Opus 4",
24
+ value: "claude-opus-4-20250514",
25
+ description: "Most capable model - best for complex tasks",
26
+ },
27
+ {
28
+ label: "Claude 3.5 Sonnet",
29
+ value: "claude-3-5-sonnet-20241022",
30
+ description: "Claude 3.5 generation Sonnet",
31
+ },
32
+ {
33
+ label: "Claude 3.5 Haiku",
34
+ value: "claude-3-5-haiku-20241022",
35
+ description: "Fastest model - great for simple tasks",
36
+ },
37
+ ];
38
+ const AVAILABLE_TOOLS = [
39
+ {
40
+ label: "Todo Write",
41
+ value: "todo_write",
42
+ description: "Task management and planning tool",
43
+ },
44
+ {
45
+ label: "Web Search",
46
+ value: "web_search",
47
+ description: "Exa-powered web search (requires EXA_API_KEY)",
48
+ },
49
+ ];
50
+ function NameInput({ nameInput, setNameInput, onSubmit }) {
51
+ useInput((_input, key) => {
52
+ if (key.escape) {
53
+ process.exit(0);
54
+ }
55
+ });
56
+ 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
+ }
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
+ function CreateApp({ name: initialName, model: initialModel, tools: initialTools, systemPrompt: initialSystemPrompt, overwrite = false, }) {
123
+ // Determine the starting stage based on what's provided
124
+ const determineInitialStage = () => {
125
+ if (!initialName)
126
+ return "name";
127
+ if (!initialModel)
128
+ return "model";
129
+ if (!initialTools || initialTools.length === 0)
130
+ return "tools";
131
+ if (!initialSystemPrompt)
132
+ return "systemPrompt";
133
+ return "review";
134
+ };
135
+ const [stage, setStage] = useState(determineInitialStage());
136
+ const [agentDef, setAgentDef] = useState({
137
+ name: initialName || "",
138
+ model: initialModel || "",
139
+ tools: initialTools ? [...initialTools] : [],
140
+ systemPrompt: initialSystemPrompt || "",
141
+ });
142
+ const [nameInput, setNameInput] = useState(initialName || "");
143
+ const [systemPromptInput, setSystemPromptInput] = useState(initialSystemPrompt || "");
144
+ const [isEditingPrompt, setIsEditingPrompt] = useState(false);
145
+ const [promptEditMode, setPromptEditMode] = useState(null);
146
+ const [reviewSelection, setReviewSelection] = useState(null);
147
+ const [isEditingFromReview, setIsEditingFromReview] = useState(false);
148
+ const [scaffoldStatus, setScaffoldStatus] = useState("pending");
149
+ const [scaffoldError, setScaffoldError] = useState(null);
150
+ const [agentPath, setAgentPath] = useState(null);
151
+ // Handle opening editor when systemPrompt stage is entered from review
152
+ 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) => {
160
+ if (editorContent !== null) {
161
+ // Editor worked
162
+ setAgentDef({ ...agentDef, systemPrompt: editorContent });
163
+ setIsEditingPrompt(false);
164
+ setIsEditingFromReview(false);
165
+ setStage("review");
166
+ }
167
+ else {
168
+ // Fallback to inline
169
+ setPromptEditMode("inline");
170
+ setIsEditingPrompt(false);
171
+ setSystemPromptInput(agentDef.systemPrompt || "You are a helpful assistant.");
172
+ }
173
+ });
174
+ }
175
+ }, [stage, isEditingFromReview, isEditingPrompt, promptEditMode, agentDef]);
176
+ // Handle scaffolding when entering "done" stage
177
+ useEffect(() => {
178
+ if (stage === "done" && scaffoldStatus === "pending") {
179
+ setScaffoldStatus("scaffolding");
180
+ // Scaffold the agent
181
+ // At this point, all required fields should be present
182
+ if (!agentDef.name || !agentDef.model) {
183
+ setScaffoldStatus("error");
184
+ return;
185
+ }
186
+ const name = agentDef.name;
187
+ const model = agentDef.model;
188
+ scaffoldAgent({
189
+ name,
190
+ definition: {
191
+ model,
192
+ systemPrompt: agentDef.systemPrompt || null,
193
+ tools: agentDef.tools || [],
194
+ },
195
+ overwrite,
196
+ }).then((result) => {
197
+ if (result.success) {
198
+ setScaffoldStatus("done");
199
+ setAgentPath(result.path);
200
+ // Output in the same format as 'town list'
201
+ const modelLabel = model.replace("claude-", "");
202
+ console.log(`\n\x1b[1mAgent created successfully!\x1b[0m\n`);
203
+ console.log(` \x1b[32m●\x1b[0m \x1b[1m${name}\x1b[0m`);
204
+ console.log(` \x1b[2mModel: ${modelLabel}\x1b[0m`);
205
+ if (agentDef.tools && agentDef.tools.length > 0) {
206
+ console.log(` \x1b[2mTools: ${agentDef.tools.join(", ")}\x1b[0m`);
207
+ }
208
+ console.log(` \x1b[2mPath: ${result.path}\x1b[0m`);
209
+ console.log();
210
+ console.log(`\x1b[2mRun an agent with: town run ${agentDef.name}\x1b[0m`);
211
+ console.log(`\x1b[2mTUI mode (default), --gui for web interface, --http for API server\x1b[0m`);
212
+ // Exit immediately
213
+ process.exit(0);
214
+ }
215
+ else {
216
+ setScaffoldStatus("error");
217
+ setScaffoldError(result.error || "Unknown error occurred");
218
+ }
219
+ });
220
+ }
221
+ }, [stage, scaffoldStatus, agentDef, overwrite]);
222
+ // Name stage
223
+ if (stage === "name") {
224
+ return (_jsx(NameInput, { nameInput: nameInput, setNameInput: setNameInput, onSubmit: () => {
225
+ if (nameInput.trim()) {
226
+ setAgentDef({ ...agentDef, name: nameInput.trim() });
227
+ if (isEditingFromReview) {
228
+ setIsEditingFromReview(false);
229
+ setStage("review");
230
+ }
231
+ else {
232
+ setStage("model");
233
+ }
234
+ }
235
+ } }));
236
+ }
237
+ // Model selection stage
238
+ if (stage === "model") {
239
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Select model for agent: ", agentDef.name] }) }), _jsx(SingleSelect, { options: AVAILABLE_MODELS, selected: agentDef.model || null, onChange: (model) => setAgentDef({ ...agentDef, model }), onSubmit: (model) => {
240
+ setAgentDef({ ...agentDef, model });
241
+ if (isEditingFromReview) {
242
+ setIsEditingFromReview(false);
243
+ setStage("review");
244
+ }
245
+ else {
246
+ setStage("tools");
247
+ }
248
+ }, onCancel: () => {
249
+ if (initialName) {
250
+ process.exit(0);
251
+ }
252
+ else {
253
+ setStage("name");
254
+ }
255
+ } })] }));
256
+ }
257
+ // Tools selection stage
258
+ if (stage === "tools") {
259
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Select tools for agent: ", agentDef.name] }) }), _jsx(MultiSelect, { options: AVAILABLE_TOOLS, selected: agentDef.tools || [], onChange: (tools) => setAgentDef({ ...agentDef, tools }), onSubmit: async () => {
260
+ // If editing from review, just go back to review
261
+ if (isEditingFromReview) {
262
+ setIsEditingFromReview(false);
263
+ setStage("review");
264
+ return;
265
+ }
266
+ // If systemPrompt was provided via flag, skip to review
267
+ if (initialSystemPrompt) {
268
+ setStage("review");
269
+ return;
270
+ }
271
+ setStage("systemPrompt");
272
+ // Attempt to open editor
273
+ setIsEditingPrompt(true);
274
+ const editorContent = await openEditor(agentDef.systemPrompt || "You are a helpful assistant.");
275
+ if (editorContent !== null) {
276
+ // Editor worked
277
+ setAgentDef({ ...agentDef, systemPrompt: editorContent });
278
+ setIsEditingPrompt(false);
279
+ setStage("review");
280
+ }
281
+ else {
282
+ // Fallback to inline
283
+ setPromptEditMode("inline");
284
+ setIsEditingPrompt(false);
285
+ setSystemPromptInput(agentDef.systemPrompt || "You are a helpful assistant.");
286
+ }
287
+ }, onCancel: () => setStage("model") })] }));
288
+ }
289
+ // System prompt stage (inline fallback)
290
+ if (stage === "systemPrompt") {
291
+ if (isEditingPrompt) {
292
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Opening editor for system prompt..." }) }), _jsx(Text, { dimColor: true, children: "You can edit the system prompt in your preferred editor." })] }));
293
+ }
294
+ if (promptEditMode === "inline") {
295
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Enter system prompt (multi-line supported):" }) }), _jsx(InputBox, { value: systemPromptInput, onChange: setSystemPromptInput, onSubmit: () => {
296
+ setAgentDef({ ...agentDef, systemPrompt: systemPromptInput });
297
+ if (isEditingFromReview) {
298
+ setIsEditingFromReview(false);
299
+ setPromptEditMode(null);
300
+ }
301
+ setStage("review");
302
+ }, onEscape: () => {
303
+ if (isEditingFromReview) {
304
+ setIsEditingFromReview(false);
305
+ setPromptEditMode(null);
306
+ setStage("review");
307
+ }
308
+ else {
309
+ setStage("tools");
310
+ }
311
+ }, isSubmitting: false, attachedFiles: [] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: Continue \u2022 Esc: Back" }) })] }));
312
+ }
313
+ return null;
314
+ }
315
+ // Review stage
316
+ if (stage === "review") {
317
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Review Agent Configuration:" }) }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Name: " }), agentDef.name] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Model: " }), agentDef.model] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Tools: " }), agentDef.tools && agentDef.tools.length > 0
318
+ ? agentDef.tools.join(", ")
319
+ : "none"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: "System Prompt:" }) }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: agentDef.systemPrompt?.split("\n").map((line) => (_jsx(Text, { dimColor: true, children: line || " " }, line))) })] }), _jsx(SingleSelect, { options: [
320
+ {
321
+ label: "Looks good, continue",
322
+ value: "continue",
323
+ description: "Proceed to next step",
324
+ },
325
+ {
326
+ label: "Edit name",
327
+ value: "name",
328
+ description: "Go back and change the agent name",
329
+ },
330
+ {
331
+ label: "Edit model",
332
+ value: "model",
333
+ description: "Go back and change the model",
334
+ },
335
+ {
336
+ label: "Edit tools",
337
+ value: "tools",
338
+ description: "Go back and change tool selection",
339
+ },
340
+ {
341
+ label: "Edit system prompt",
342
+ value: "systemPrompt",
343
+ description: "Go back and change the system prompt",
344
+ },
345
+ ], selected: reviewSelection, onChange: (value) => {
346
+ setReviewSelection(value);
347
+ }, onSubmit: (value) => {
348
+ // Navigate based on selection
349
+ setReviewSelection(value);
350
+ if (value === "continue") {
351
+ setStage("done");
352
+ }
353
+ else if (value === "name" ||
354
+ value === "model" ||
355
+ value === "tools" ||
356
+ value === "systemPrompt") {
357
+ // Set flag so we return to review after editing
358
+ setIsEditingFromReview(true);
359
+ setStage(value);
360
+ }
361
+ }, onCancel: () => setStage("tools") })] }));
362
+ }
363
+ // Done stage
364
+ if (stage === "done") {
365
+ if (scaffoldStatus === "scaffolding") {
366
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "\u23F3 Creating agent package..." }) }));
367
+ }
368
+ if (scaffoldStatus === "error") {
369
+ 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 }) })] }));
370
+ }
371
+ if (scaffoldStatus === "done") {
372
+ 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] })] }) })] }));
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+ export async function createCommand(props) {
378
+ // Set stdin to raw mode to capture input
379
+ if (process.stdin.isTTY) {
380
+ process.stdin.setRawMode(true);
381
+ }
382
+ const { waitUntilExit } = render(_jsx(CreateApp, { ...props }));
383
+ // Wait for the app to exit before returning
384
+ await waitUntilExit();
385
+ }
@@ -0,0 +1 @@
1
+ export declare function deleteCommand(name: string): Promise<void>;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { agentExists, deleteAgent } from "@townco/agent/storage";
3
+ import { SingleSelect } from "@townco/ui/tui";
4
+ import { Box, render, Text } from "ink";
5
+ import { useState } from "react";
6
+ function DeleteApp({ name }) {
7
+ const [confirmed, setConfirmed] = useState(null);
8
+ if (confirmed === null) {
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ["Are you sure you want to delete agent", " ", _jsx(Text, { color: "red", children: name }), "?"] }) }), _jsx(Text, { dimColor: true, children: "This action cannot be undone." }), _jsx(Box, { marginTop: 1, children: _jsx(SingleSelect, { options: [
10
+ {
11
+ label: "Yes, delete",
12
+ value: "yes",
13
+ description: "Permanently delete this agent",
14
+ },
15
+ {
16
+ label: "No, cancel",
17
+ value: "no",
18
+ description: "Keep the agent",
19
+ },
20
+ ], selected: null, onChange: () => { }, onSubmit: async (value) => {
21
+ if (value === "yes") {
22
+ setConfirmed(true);
23
+ try {
24
+ await deleteAgent(name);
25
+ setTimeout(() => process.exit(0), 1500);
26
+ }
27
+ catch (error) {
28
+ console.error(`Error deleting agent: ${error instanceof Error ? error.message : String(error)}`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+ else {
33
+ setConfirmed(false);
34
+ setTimeout(() => process.exit(0), 500);
35
+ }
36
+ }, onCancel: () => {
37
+ setConfirmed(false);
38
+ setTimeout(() => process.exit(0), 500);
39
+ } }) })] }));
40
+ }
41
+ if (confirmed) {
42
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Agent deleted successfully" }) }));
43
+ }
44
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Cancelled" }) }));
45
+ }
46
+ export async function deleteCommand(name) {
47
+ // Check if agent exists
48
+ const exists = await agentExists(name);
49
+ if (!exists) {
50
+ console.error(`Error: Agent "${name}" not found.`);
51
+ console.log('\nList agents with "town list"');
52
+ process.exit(1);
53
+ }
54
+ // Set stdin to raw mode to capture input
55
+ if (process.stdin.isTTY) {
56
+ process.stdin.setRawMode(true);
57
+ }
58
+ const { waitUntilExit } = render(_jsx(DeleteApp, { name: name }));
59
+ await waitUntilExit();
60
+ }
@@ -0,0 +1 @@
1
+ export declare function editCommand(name: string): Promise<void>;
@@ -0,0 +1,82 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { agentExists, getAgentPath } from "@townco/agent/storage";
4
+ import { createCommand } from "./create.js";
5
+ async function loadAgentConfig(name) {
6
+ try {
7
+ const agentPath = getAgentPath(name);
8
+ const indexPath = join(agentPath, "index.ts");
9
+ const content = await readFile(indexPath, "utf-8");
10
+ // Parse model
11
+ const modelMatch = content.match(/model:\s*"([^"]+)"/);
12
+ if (!modelMatch) {
13
+ throw new Error("Failed to parse model from agent configuration");
14
+ }
15
+ const model = modelMatch[1];
16
+ // Parse tools
17
+ const toolsMatch = content.match(/tools:\s*\[([^\]]*)\]/);
18
+ let tools = [];
19
+ if (toolsMatch?.[1]) {
20
+ const toolsStr = toolsMatch[1];
21
+ tools = toolsStr
22
+ .split(",")
23
+ .map((t) => t.trim().replace(/["']/g, ""))
24
+ .filter((t) => t.length > 0);
25
+ }
26
+ // Parse systemPrompt - handle multiline strings and null
27
+ const systemPromptMatch = content.match(/systemPrompt:\s*(null|"([^"]*)"|`([^`]*)`)/s);
28
+ let systemPrompt;
29
+ if (systemPromptMatch) {
30
+ if (systemPromptMatch[1] === "null") {
31
+ systemPrompt = "";
32
+ }
33
+ else {
34
+ // Get either double-quoted (group 2) or backtick-quoted (group 3) content
35
+ systemPrompt = systemPromptMatch[2] || systemPromptMatch[3] || "";
36
+ }
37
+ }
38
+ if (systemPrompt !== undefined && systemPrompt !== "") {
39
+ return {
40
+ name,
41
+ model: model,
42
+ tools,
43
+ systemPrompt,
44
+ };
45
+ }
46
+ return {
47
+ name,
48
+ model: model,
49
+ tools,
50
+ };
51
+ }
52
+ catch (error) {
53
+ console.error(`Error loading agent config: ${error instanceof Error ? error.message : String(error)}`);
54
+ return null;
55
+ }
56
+ }
57
+ export async function editCommand(name) {
58
+ // Check if agent exists
59
+ const exists = await agentExists(name);
60
+ if (!exists) {
61
+ console.error(`Error: Agent "${name}" not found.`);
62
+ console.log('\nCreate an agent with "town create" or list agents with "town list"');
63
+ process.exit(1);
64
+ }
65
+ // Load existing config
66
+ const config = await loadAgentConfig(name);
67
+ if (!config) {
68
+ console.error(`Error: Failed to load agent configuration for "${name}".`);
69
+ process.exit(1);
70
+ }
71
+ console.log(`Editing agent: ${name}\n`);
72
+ // Reuse create command with existing values and overwrite flag
73
+ await createCommand({
74
+ name: config.name,
75
+ model: config.model,
76
+ tools: config.tools,
77
+ ...(config.systemPrompt !== undefined && {
78
+ systemPrompt: config.systemPrompt,
79
+ }),
80
+ overwrite: true,
81
+ });
82
+ }
@@ -0,0 +1 @@
1
+ export declare function listCommand(): Promise<void>;
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getAgentPath, listAgents } from "@townco/agent/storage";
4
+ async function getAgentInfo(name) {
5
+ try {
6
+ const agentPath = getAgentPath(name);
7
+ const configPath = join(agentPath, "agent.json");
8
+ const content = await readFile(configPath, "utf-8");
9
+ const config = JSON.parse(content);
10
+ return {
11
+ name,
12
+ model: config.model,
13
+ tools: config.tools || [],
14
+ };
15
+ }
16
+ catch (error) {
17
+ return {
18
+ name,
19
+ error: error instanceof Error ? error.message : "Failed to read agent",
20
+ };
21
+ }
22
+ }
23
+ export async function listCommand() {
24
+ const agents = await listAgents();
25
+ if (agents.length === 0) {
26
+ console.log("No agents found.");
27
+ console.log('\x1b[2mCreate your first agent with "town create"\x1b[0m');
28
+ return;
29
+ }
30
+ console.log(`\x1b[1mFound ${agents.length} agent${agents.length === 1 ? "" : "s"}:\x1b[0m\n`);
31
+ // Get info for all agents
32
+ const agentInfos = await Promise.all(agents.map((name) => getAgentInfo(name)));
33
+ // Display each agent
34
+ for (const info of agentInfos) {
35
+ const agentPath = getAgentPath(info.name);
36
+ if (info.error) {
37
+ console.log(` \x1b[31m✗\x1b[0m \x1b[1m${info.name}\x1b[0m`);
38
+ console.log(` \x1b[2mError: ${info.error}\x1b[0m`);
39
+ }
40
+ else {
41
+ const modelLabel = info.model
42
+ ? info.model.replace("claude-", "")
43
+ : "unknown";
44
+ console.log(` \x1b[32m●\x1b[0m \x1b[1m${info.name}\x1b[0m`);
45
+ console.log(` \x1b[2mModel: ${modelLabel}\x1b[0m`);
46
+ if (info.tools && info.tools.length > 0) {
47
+ console.log(` \x1b[2mTools: ${info.tools.join(", ")}\x1b[0m`);
48
+ }
49
+ console.log(` \x1b[2mPath: ${agentPath}\x1b[0m`);
50
+ }
51
+ console.log(); // Empty line between agents
52
+ }
53
+ console.log(`\x1b[2mRun an agent with: town run <name>\x1b[0m`);
54
+ console.log(`\x1b[2mTUI mode (default), --gui for web interface, --http for API server\x1b[0m`);
55
+ }
@@ -0,0 +1,7 @@
1
+ export interface RunCommandOptions {
2
+ name: string;
3
+ http?: boolean;
4
+ gui?: boolean;
5
+ port?: number;
6
+ }
7
+ export declare function runCommand(options: RunCommandOptions): Promise<void>;
@@ -0,0 +1,129 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { agentExists, getAgentPath } from "@townco/agent/storage";
4
+ export async function runCommand(options) {
5
+ const { name, http = false, gui = false, port = 3100 } = options;
6
+ // Check if agent exists
7
+ const exists = await agentExists(name);
8
+ if (!exists) {
9
+ console.error(`Error: Agent "${name}" not found.`);
10
+ console.log('\nCreate an agent with "town create" or list agents with "town list"');
11
+ process.exit(1);
12
+ }
13
+ const agentPath = getAgentPath(name);
14
+ const binPath = join(agentPath, "bin.ts");
15
+ // If GUI or HTTP-only mode, run differently
16
+ if (gui) {
17
+ const guiPath = join(agentPath, "gui");
18
+ // Check if GUI exists
19
+ try {
20
+ const { stat } = await import("node:fs/promises");
21
+ await stat(guiPath);
22
+ }
23
+ catch {
24
+ console.error(`Error: GUI not found for agent "${name}".`);
25
+ console.log(`\nThe GUI was not bundled with this agent.`);
26
+ console.log(`Recreate the agent with "town create" to include the GUI.`);
27
+ process.exit(1);
28
+ }
29
+ console.log(`Starting agent "${name}" with GUI...`);
30
+ console.log(`Starting agent HTTP server on port ${port}...`);
31
+ console.log(`Starting GUI development server...\n`);
32
+ // Start the agent in HTTP mode first
33
+ const agentProcess = spawn("bun", [binPath, "http"], {
34
+ cwd: agentPath,
35
+ stdio: "pipe",
36
+ env: {
37
+ ...process.env,
38
+ NODE_ENV: process.env.NODE_ENV || "production",
39
+ PORT: port.toString(),
40
+ },
41
+ });
42
+ agentProcess.on("error", (error) => {
43
+ console.error(`Failed to start agent: ${error.message}`);
44
+ process.exit(1);
45
+ });
46
+ // Wait a bit for agent to start, then start the GUI
47
+ setTimeout(() => {
48
+ // Start the GUI dev server
49
+ const guiProcess = spawn("bun", ["run", "dev"], {
50
+ cwd: guiPath,
51
+ stdio: "inherit",
52
+ env: {
53
+ ...process.env,
54
+ VITE_AGENT_URL: `http://localhost:${port}`,
55
+ },
56
+ });
57
+ guiProcess.on("error", (error) => {
58
+ console.error(`Failed to start GUI: ${error.message}`);
59
+ agentProcess.kill();
60
+ process.exit(1);
61
+ });
62
+ guiProcess.on("close", (code) => {
63
+ agentProcess.kill();
64
+ if (code !== 0 && code !== null) {
65
+ console.error(`GUI exited with code ${code}`);
66
+ process.exit(code);
67
+ }
68
+ });
69
+ }, 1000);
70
+ agentProcess.on("close", (code) => {
71
+ if (code !== 0 && code !== null) {
72
+ console.error(`Agent exited with code ${code}`);
73
+ process.exit(code);
74
+ }
75
+ });
76
+ return;
77
+ }
78
+ else if (http) {
79
+ console.log(`Starting agent "${name}" in HTTP mode on port ${port}...`);
80
+ console.log(`\nEndpoints:`);
81
+ console.log(` http://localhost:${port}/health - Health check`);
82
+ console.log(` http://localhost:${port}/rpc - RPC endpoint`);
83
+ console.log(` http://localhost:${port}/events - SSE event stream\n`);
84
+ // Run the agent in HTTP mode
85
+ const agentProcess = spawn("bun", [binPath, "http"], {
86
+ cwd: agentPath,
87
+ stdio: "inherit",
88
+ env: {
89
+ ...process.env,
90
+ NODE_ENV: process.env.NODE_ENV || "production",
91
+ PORT: port.toString(),
92
+ },
93
+ });
94
+ agentProcess.on("error", (error) => {
95
+ console.error(`Failed to start agent: ${error.message}`);
96
+ process.exit(1);
97
+ });
98
+ agentProcess.on("close", (code) => {
99
+ if (code !== 0 && code !== null) {
100
+ console.error(`Agent exited with code ${code}`);
101
+ process.exit(code);
102
+ }
103
+ });
104
+ return;
105
+ }
106
+ // Default: Start TUI interface with the agent
107
+ console.log(`Starting interactive terminal for agent "${name}"...\n`);
108
+ // Get path to TUI app
109
+ const tuiPath = join(process.cwd(), "apps", "tui", "src", "index.tsx");
110
+ // Run TUI with the agent
111
+ const tuiProcess = spawn("bun", [tuiPath, "--agent", binPath], {
112
+ cwd: agentPath,
113
+ stdio: "inherit",
114
+ env: {
115
+ ...process.env,
116
+ NODE_ENV: process.env.NODE_ENV || "production",
117
+ },
118
+ });
119
+ tuiProcess.on("error", (error) => {
120
+ console.error(`Failed to start TUI: ${error.message}`);
121
+ process.exit(1);
122
+ });
123
+ tuiProcess.on("close", (code) => {
124
+ if (code !== 0 && code !== null) {
125
+ console.error(`TUI exited with code ${code}`);
126
+ process.exit(code);
127
+ }
128
+ });
129
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bun
2
+ import { argument, command, constant, flag, multiple, object, option, optional, or, } from "@optique/core";
3
+ import { message } from "@optique/core/message";
4
+ import { integer, string } from "@optique/core/valueparser";
5
+ import { run } from "@optique/run";
6
+ import { createSecret, deleteSecret, genenv, listSecrets } from "@townco/secret";
7
+ import inquirer from "inquirer";
8
+ import { match } from "ts-pattern";
9
+ import { createCommand } from "./commands/create.js";
10
+ import { deleteCommand } from "./commands/delete.js";
11
+ import { editCommand } from "./commands/edit.js";
12
+ import { listCommand } from "./commands/list.js";
13
+ import { runCommand } from "./commands/run.js";
14
+ /**
15
+ * Securely prompt for a secret value without echoing to the terminal
16
+ */
17
+ async function promptSecret(secretName) {
18
+ const answers = await inquirer.prompt([
19
+ {
20
+ type: "password",
21
+ name: "value",
22
+ message: `Enter value for secret '${secretName}':`,
23
+ mask: "*",
24
+ },
25
+ ]);
26
+ return answers.value;
27
+ }
28
+ const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy a Town.` }), command("create", object({
29
+ command: constant("create"),
30
+ name: optional(option("-n", "--name", string())),
31
+ model: optional(option("-m", "--model", string())),
32
+ tools: multiple(option("-t", "--tool", string())),
33
+ systemPrompt: optional(option("-p", "--prompt", string())),
34
+ }), { brief: message `Create a new agent.` }), command("list", constant("list"), { brief: message `List all agents.` }), command("run", object({
35
+ command: constant("run"),
36
+ name: argument(string({ metavar: "NAME" })),
37
+ http: optional(flag("--http")),
38
+ gui: optional(flag("--gui")),
39
+ port: optional(option("-p", "--port", integer())),
40
+ }), { brief: message `Run an agent.` }), command("edit", object({
41
+ command: constant("edit"),
42
+ name: argument(string({ metavar: "NAME" })),
43
+ }), { brief: message `Edit an agent.` }), command("delete", object({
44
+ command: constant("delete"),
45
+ name: argument(string({ metavar: "NAME" })),
46
+ }), { brief: message `Delete an agent.` }), command("secret", object({
47
+ command: constant("secret"),
48
+ subcommand: or(command("list", constant("list"), { brief: message `List secrets.` }), command("add", object({
49
+ action: constant("add"),
50
+ name: argument(string({ metavar: "NAME" })),
51
+ value: optional(argument(string({ metavar: "VALUE" }))),
52
+ }), { brief: message `Add a secret.` }), command("remove", object({
53
+ action: constant("remove"),
54
+ name: argument(string({ metavar: "NAME" })),
55
+ }), { brief: message `Remove a secret.` }), command("genenv", constant("genenv"), {
56
+ brief: message `Generate .env file.`,
57
+ })),
58
+ }), { brief: message `Secrets management.` }));
59
+ const meta = {
60
+ programName: "town",
61
+ help: "both",
62
+ version: "both",
63
+ completion: "both",
64
+ brief: message `Your one-stop shop for all things Town in the terminal\n`,
65
+ description: message `Town CLI is a first-class command-line experience for working with Town Agents.`,
66
+ };
67
+ async function main(parser, meta) {
68
+ const result = run(parser, meta);
69
+ await match(result)
70
+ // TODO
71
+ .with("deploy", async () => { })
72
+ .with({ command: "create" }, async ({ name, model, tools, systemPrompt }) => {
73
+ // Create command starts a long-running Ink session
74
+ // Only pass defined properties to satisfy exactOptionalPropertyTypes
75
+ await createCommand({
76
+ ...(name !== undefined && { name }),
77
+ ...(model !== undefined && { model }),
78
+ ...(tools.length > 0 && { tools }),
79
+ ...(systemPrompt !== undefined && { systemPrompt }),
80
+ });
81
+ })
82
+ .with("list", async () => {
83
+ await listCommand();
84
+ })
85
+ .with({ command: "run" }, async ({ name, http, gui, port }) => {
86
+ const options = {
87
+ name,
88
+ http: http === true,
89
+ gui: gui === true,
90
+ };
91
+ if (port !== null && port !== undefined) {
92
+ options.port = port;
93
+ }
94
+ await runCommand(options);
95
+ })
96
+ .with({ command: "edit" }, async ({ name }) => {
97
+ await editCommand(name);
98
+ })
99
+ .with({ command: "delete" }, async ({ name }) => {
100
+ await deleteCommand(name);
101
+ })
102
+ .with({ command: "secret" }, async ({ subcommand }) => {
103
+ await match(subcommand)
104
+ .with("list", async () => {
105
+ const secrets = await listSecrets();
106
+ for (const secret of secrets) {
107
+ console.log(`${secret.key}\t${secret.value}\t${secret.valid ? "✓" : "✗"}\t${secret.error ?? ""}`);
108
+ }
109
+ })
110
+ .with({ action: "add" }, async ({ name, value }) => {
111
+ // If value is not provided, prompt securely
112
+ const secretValue = value ?? (await promptSecret(name));
113
+ if (!secretValue) {
114
+ console.error("Error: Secret value cannot be empty");
115
+ process.exit(1);
116
+ }
117
+ await createSecret(name, secretValue);
118
+ console.log(`Secret '${name}' added successfully.`);
119
+ })
120
+ .with({ action: "remove" }, async ({ name }) => {
121
+ await deleteSecret(name);
122
+ console.log(`Secret '${name}' removed successfully.`);
123
+ })
124
+ .with("genenv", async () => {
125
+ await genenv();
126
+ console.log(".env file generated successfully.");
127
+ })
128
+ .exhaustive();
129
+ })
130
+ .exhaustive();
131
+ }
132
+ main(parser, meta).catch((error) => {
133
+ console.error("Error:", error);
134
+ process.exit(1);
135
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@townco/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": {
8
+ "townco": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/federicoweber/agent_hub.git"
17
+ },
18
+ "author": "Federico Weber",
19
+ "engines": {
20
+ "bun": ">=1.3.0"
21
+ },
22
+ "scripts": {
23
+ "check": "tsc --noEmit",
24
+ "build": "tsc"
25
+ },
26
+ "devDependencies": {
27
+ "@townco/tsconfig": "^0.1.0",
28
+ "@types/bun": "^1.3.1",
29
+ "@types/react": "^19.2.2"
30
+ },
31
+ "dependencies": {
32
+ "@optique/core": "^0.6.2",
33
+ "@optique/run": "^0.6.2",
34
+ "@townco/secret": "^0.1.0",
35
+ "@townco/ui": "^0.1.0",
36
+ "@townco/agent": "^0.1.0",
37
+ "@types/inquirer": "^9.0.9",
38
+ "ink": "^6.4.0",
39
+ "ink-text-input": "^6.0.0",
40
+ "inquirer": "^12.10.0",
41
+ "react": "^19.2.0",
42
+ "ts-pattern": "^5.9.0"
43
+ }
44
+ }