@townco/cli 0.1.21 → 0.1.23

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,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import { isInsideTownProject } from "@townco/agent/storage";
5
5
  import inquirer from "inquirer";
6
6
  const ENV_KEYS = [
7
7
  {
@@ -20,14 +20,20 @@ const ENV_KEYS = [
20
20
  required: false,
21
21
  },
22
22
  ];
23
- function getConfigDir() {
24
- return join(homedir(), ".config", "town");
23
+ async function getProjectRoot() {
24
+ const projectRoot = await isInsideTownProject();
25
+ if (projectRoot === null) {
26
+ console.error("āŒ Error: Not inside a Town project.");
27
+ console.error("\nRun `town create --init <path>` to initialize a project first.");
28
+ process.exit(1);
29
+ }
30
+ return projectRoot;
25
31
  }
26
- function getEnvFilePath() {
27
- return join(getConfigDir(), ".env");
32
+ function getEnvFilePath(projectRoot) {
33
+ return join(projectRoot, ".env");
28
34
  }
29
- async function loadExistingEnv() {
30
- const envPath = getEnvFilePath();
35
+ async function loadExistingEnv(projectRoot) {
36
+ const envPath = getEnvFilePath(projectRoot);
31
37
  if (!existsSync(envPath)) {
32
38
  return {};
33
39
  }
@@ -45,9 +51,9 @@ async function loadExistingEnv() {
45
51
  }
46
52
  return config;
47
53
  }
48
- async function saveEnv(config) {
49
- const configDir = getConfigDir();
50
- await mkdir(configDir, { recursive: true });
54
+ async function saveEnv(config, projectRoot) {
55
+ // Ensure project root exists (it should, but just to be safe)
56
+ await mkdir(projectRoot, { recursive: true });
51
57
  const lines = [
52
58
  "# Town CLI Configuration",
53
59
  "# Environment variables for Town agents",
@@ -61,11 +67,13 @@ async function saveEnv(config) {
61
67
  lines.push("");
62
68
  }
63
69
  }
64
- await writeFile(getEnvFilePath(), lines.join("\n"), "utf-8");
70
+ await writeFile(getEnvFilePath(projectRoot), lines.join("\n"), "utf-8");
65
71
  }
66
72
  export async function configureCommand() {
67
73
  console.log("šŸ”§ Town Configuration\n");
68
- const existingConfig = await loadExistingEnv();
74
+ // Get project root (will error and exit if not in a project)
75
+ const projectRoot = await getProjectRoot();
76
+ const existingConfig = await loadExistingEnv(projectRoot);
69
77
  const hasExisting = Object.keys(existingConfig).length > 0;
70
78
  if (hasExisting) {
71
79
  console.log("Found existing configuration:\n");
@@ -140,7 +148,7 @@ export async function configureCommand() {
140
148
  }
141
149
  }
142
150
  // Save configuration
143
- await saveEnv(newConfig);
144
- console.log(`\nāœ… Configuration saved to ${getEnvFilePath()}`);
151
+ await saveEnv(newConfig, projectRoot);
152
+ console.log(`\nāœ… Configuration saved to ${getEnvFilePath(projectRoot)}`);
145
153
  console.log("\nThese environment variables will be automatically loaded when running agents.");
146
154
  }
@@ -0,0 +1,7 @@
1
+ export interface CreateProjectCommandProps {
2
+ path?: string;
3
+ }
4
+ /**
5
+ * Create a new standalone project with agents and tools
6
+ */
7
+ export declare function createProjectCommand(props?: CreateProjectCommandProps): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { resolve } from "node:path";
2
+ import { scaffoldProject } from "@townco/agent/scaffold";
3
+ /**
4
+ * Create a new standalone project with agents and tools
5
+ */
6
+ export async function createProjectCommand(props = {}) {
7
+ const { path = process.cwd() } = props;
8
+ // Resolve the path to absolute
9
+ const projectPath = resolve(path);
10
+ console.log(`Creating new Town agents project at: ${projectPath}`);
11
+ const result = await scaffoldProject({
12
+ path: projectPath,
13
+ });
14
+ if (result.success) {
15
+ console.log("\nāœ“ Project created successfully!");
16
+ console.log(`\nProject path: ${result.path}`);
17
+ }
18
+ else {
19
+ console.error(`\nāœ— Error creating project: ${result.error}`);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -1,8 +1,10 @@
1
1
  interface CreateCommandProps {
2
- name?: string;
3
- model?: string;
4
- tools?: readonly string[];
5
- systemPrompt?: string;
6
- overwrite?: boolean;
2
+ name?: string;
3
+ model?: string;
4
+ tools?: readonly string[];
5
+ systemPrompt?: string;
6
+ overwrite?: boolean;
7
+ agentsDir: string;
7
8
  }
8
9
  export declare function createCommand(props: CreateCommandProps): Promise<void>;
10
+ export {};
@@ -52,7 +52,7 @@ function NameInput({ nameInput, setNameInput, onSubmit }) {
52
52
  });
53
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" }) })] }));
54
54
  }
55
- function CreateApp({ name: initialName, model: initialModel, tools: initialTools, systemPrompt: initialSystemPrompt, overwrite = false, }) {
55
+ function CreateApp({ name: initialName, model: initialModel, tools: initialTools, systemPrompt: initialSystemPrompt, overwrite = false, agentsDir, }) {
56
56
  // Determine the starting stage based on what's provided
57
57
  const determineInitialStage = () => {
58
58
  if (!initialName)
@@ -119,14 +119,17 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
119
119
  }
120
120
  const name = agentDef.name;
121
121
  const model = agentDef.model;
122
+ const definition = {
123
+ model,
124
+ systemPrompt: agentDef.systemPrompt || null,
125
+ tools: agentDef.tools || [],
126
+ };
127
+ // Create agent in project
122
128
  scaffoldAgent({
123
129
  name,
124
- definition: {
125
- model,
126
- systemPrompt: agentDef.systemPrompt || null,
127
- tools: agentDef.tools || [],
128
- },
130
+ definition,
129
131
  overwrite,
132
+ agentsDir,
130
133
  }).then((result) => {
131
134
  if (result.success) {
132
135
  setScaffoldStatus("done");
@@ -142,7 +145,7 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
142
145
  }
143
146
  });
144
147
  }
145
- }, [stage, scaffoldStatus, agentDef, overwrite]);
148
+ }, [stage, scaffoldStatus, agentDef, overwrite, agentsDir]);
146
149
  // Name stage
147
150
  if (stage === "name") {
148
151
  return (_jsx(NameInput, { nameInput: nameInput, setNameInput: setNameInput, onSubmit: () => {
@@ -294,7 +297,7 @@ function CreateApp({ name: initialName, model: initialModel, tools: initialTools
294
297
  }
295
298
  if (scaffoldStatus === "done") {
296
299
  const modelLabel = agentDef.model?.replace("claude-", "") || "";
297
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Agent created successfully!" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u25CF" }), " ", _jsx(Text, { bold: true, children: agentDef.name })] }), _jsxs(Text, { dimColor: true, children: [" Model: ", modelLabel] }), agentDef.tools && agentDef.tools.length > 0 && (_jsxs(Text, { dimColor: true, children: [" Tools: ", agentDef.tools.join(", ")] })), _jsxs(Text, { dimColor: true, children: [" Path: ", agentPath] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Run an agent with: town run ", agentDef.name] }), _jsx(Text, { dimColor: true, children: "TUI mode (default), --gui for web interface, --http for API server" })] })] }));
300
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Agent created successfully!" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u25CF" }), " ", _jsx(Text, { bold: true, children: agentDef.name })] }), _jsxs(Text, { dimColor: true, children: [" Model: ", modelLabel] }), agentDef.tools && agentDef.tools.length > 0 && (_jsxs(Text, { dimColor: true, children: [" Tools: ", agentDef.tools.join(", ")] })), _jsxs(Text, { dimColor: true, children: [" Path: ", agentPath] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Run agent: bun agents/", agentDef.name, "/index.ts stdio"] }), _jsxs(Text, { dimColor: true, children: ["GUI: cd agents/", agentDef.name, "/gui && bun run dev"] }), _jsxs(Text, { dimColor: true, children: ["TUI: cd agents/", agentDef.name, "/tui && bun start"] })] })] }));
298
301
  }
299
302
  }
300
303
  return null;
@@ -1,10 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { agentExists, getAgentPath } from "@townco/agent/storage";
3
+ import { isInsideTownProject } from "@townco/agent/storage";
4
4
  import { createCommand } from "./create.js";
5
- async function loadAgentConfig(name) {
5
+ async function loadAgentConfig(name, agentPath) {
6
6
  try {
7
- const agentPath = getAgentPath(name);
8
7
  const indexPath = join(agentPath, "index.ts");
9
8
  const content = await readFile(indexPath, "utf-8");
10
9
  // Parse model
@@ -55,15 +54,25 @@ async function loadAgentConfig(name) {
55
54
  }
56
55
  }
57
56
  export async function editCommand(name) {
58
- // Check if agent exists
59
- const exists = await agentExists(name);
60
- if (!exists) {
57
+ // Check if we're inside a Town project
58
+ const projectRoot = await isInsideTownProject();
59
+ if (projectRoot === null) {
60
+ console.error("Error: Not inside a Town project.\n\n" +
61
+ "Please run 'town edit' inside a project directory.");
62
+ process.exit(1);
63
+ }
64
+ const agentPath = join(projectRoot, "agents", name);
65
+ // Check if the agent exists
66
+ try {
67
+ await readFile(join(agentPath, "agent.json"), "utf-8");
68
+ }
69
+ catch {
61
70
  console.error(`Error: Agent "${name}" not found.`);
62
71
  console.log('\nCreate an agent with "town create" or list agents with "town list"');
63
72
  process.exit(1);
64
73
  }
65
74
  // Load existing config
66
- const config = await loadAgentConfig(name);
75
+ const config = await loadAgentConfig(name, agentPath);
67
76
  if (!config) {
68
77
  console.error(`Error: Failed to load agent configuration for "${name}".`);
69
78
  process.exit(1);
@@ -78,5 +87,6 @@ export async function editCommand(name) {
78
87
  systemPrompt: config.systemPrompt,
79
88
  }),
80
89
  overwrite: true,
90
+ agentsDir: join(projectRoot, "agents"),
81
91
  });
82
92
  }
@@ -1,14 +1,9 @@
1
1
  interface MCPAddProps {
2
- name?: string;
3
- url?: string;
4
- command?: string;
5
- args?: readonly string[];
2
+ name?: string;
3
+ url?: string;
4
+ command?: string;
5
+ args?: readonly string[];
6
6
  }
7
- declare function MCPAddApp({
8
- name: initialName,
9
- url: initialUrl,
10
- command: initialCommand,
11
- args: initialArgs,
12
- }: MCPAddProps): import("react/jsx-runtime").JSX.Element | null;
7
+ declare function MCPAddApp({ name: initialName, url: initialUrl, command: initialCommand, args: initialArgs, }: MCPAddProps): import("react/jsx-runtime").JSX.Element | null;
13
8
  export default MCPAddApp;
14
9
  export declare function runMCPAdd(props?: MCPAddProps): Promise<void>;
@@ -2,9 +2,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
6
5
  import { join } from "node:path";
7
- import { agentExists, getAgentPath } from "@townco/agent/storage";
6
+ import { isInsideTownProject } from "@townco/agent/storage";
8
7
  import { createLogger } from "@townco/agent/utils";
9
8
  import { AcpClient } from "@townco/ui";
10
9
  import { ChatView } from "@townco/ui/tui";
@@ -100,10 +99,11 @@ function GuiRunner({ agentProcess, guiProcess, agentPort, agentPath, logger, onE
100
99
  ], [agentProcess, guiProcess, agentPort]);
101
100
  return (_jsx(TabbedOutput, { processes: processes, logsDir: join(agentPath, ".logs"), onExit: onExit, onPortDetected: handlePortDetected }));
102
101
  }
103
- async function loadEnvVars(logger) {
104
- const envPath = join(homedir(), ".config", "town", ".env");
102
+ async function loadEnvVars(projectRoot, logger) {
103
+ const envPath = join(projectRoot, ".env");
105
104
  const envVars = {};
106
105
  if (!existsSync(envPath)) {
106
+ logger?.debug("No .env file found in project root", { path: envPath });
107
107
  return envVars;
108
108
  }
109
109
  try {
@@ -133,16 +133,29 @@ async function loadEnvVars(logger) {
133
133
  }
134
134
  export async function runCommand(options) {
135
135
  const { name, http = false, gui = false, port = 3100 } = options;
136
- // Load environment variables from ~/.config/town/.env
137
- const configEnvVars = await loadEnvVars();
136
+ // Check if we're inside a Town project
137
+ const projectRoot = await isInsideTownProject();
138
+ if (projectRoot === null) {
139
+ console.error("Error: Not inside a Town project.");
140
+ console.log('\nPlease run "town run" inside a project directory, or run:\n' +
141
+ " town create --init <path>\n" +
142
+ "to create a project.");
143
+ process.exit(1);
144
+ }
145
+ // Load environment variables from project .env
146
+ const configEnvVars = await loadEnvVars(projectRoot);
147
+ // Resolve agent path within the project
148
+ const agentPath = join(projectRoot, "agents", name);
138
149
  // Check if agent exists
139
- const exists = await agentExists(name);
140
- if (!exists) {
150
+ try {
151
+ const { stat } = await import("node:fs/promises");
152
+ await stat(agentPath);
153
+ }
154
+ catch {
141
155
  console.error(`Error: Agent "${name}" not found.`);
142
156
  console.log('\nCreate an agent with "town create" or list agents with "town list"');
143
157
  process.exit(1);
144
158
  }
145
- const agentPath = getAgentPath(name);
146
159
  const binPath = join(agentPath, "bin.ts");
147
160
  // Create logger with agent directory as logs location
148
161
  const logger = createLogger("cli", "debug", {
@@ -200,8 +213,8 @@ export async function runCommand(options) {
200
213
  PORT: availablePort.toString(),
201
214
  },
202
215
  });
203
- // Start the GUI dev server
204
- const guiProcess = spawn("bun", ["run", "dev"], {
216
+ // Start the GUI dev server (no package.json, run vite directly)
217
+ const guiProcess = spawn("bunx", ["vite"], {
205
218
  cwd: guiPath,
206
219
  stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
207
220
  env: {
@@ -0,0 +1,6 @@
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>;
@@ -0,0 +1,376 @@
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
+ }
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env bun
2
+ import { join } from "node:path";
2
3
  import { argument, command, constant, flag, multiple, object, option, optional, or, } from "@optique/core";
3
4
  import { message } from "@optique/core/message";
4
5
  import { integer, string } from "@optique/core/valueparser";
5
6
  import { run } from "@optique/run";
7
+ import { isInsideTownProject } from "@townco/agent/storage";
6
8
  import { createSecret, deleteSecret, genenv, listSecrets, } from "@townco/secret";
7
9
  import inquirer from "inquirer";
8
10
  import { match } from "ts-pattern";
9
11
  import { configureCommand } from "./commands/configure.js";
10
12
  import { createCommand } from "./commands/create.js";
13
+ import { createProjectCommand } from "./commands/create-project.js";
11
14
  import { deleteCommand } from "./commands/delete.js";
12
15
  import { editCommand } from "./commands/edit.js";
13
16
  import { listCommand } from "./commands/list.js";
@@ -41,7 +44,8 @@ const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy
41
44
  model: optional(option("-m", "--model", string())),
42
45
  tools: multiple(option("-t", "--tool", string())),
43
46
  systemPrompt: optional(option("-p", "--prompt", string())),
44
- }), { brief: message `Create a new agent.` }), command("list", constant("list"), { brief: message `List all agents.` }), command("run", object({
47
+ init: optional(option("--init", string())),
48
+ }), { brief: message `Create a new agent or project (with --init <path>).` }), command("list", constant("list"), { brief: message `List all agents.` }), command("run", object({
45
49
  command: constant("run"),
46
50
  name: argument(string({ metavar: "NAME" })),
47
51
  http: optional(flag("--http")),
@@ -109,15 +113,59 @@ async function main(parser, meta) {
109
113
  .with("configure", async () => {
110
114
  await configureCommand();
111
115
  })
112
- .with({ command: "create" }, async ({ name, model, tools, systemPrompt }) => {
113
- // Create command starts a long-running Ink session
114
- // Only pass defined properties to satisfy exactOptionalPropertyTypes
115
- await createCommand({
116
- ...(name !== undefined && { name }),
117
- ...(model !== undefined && { model }),
118
- ...(tools.length > 0 && { tools }),
119
- ...(systemPrompt !== undefined && { systemPrompt }),
120
- });
116
+ .with({ command: "create" }, async ({ name, model, tools, systemPrompt, init }) => {
117
+ // Check if --init flag is present for project scaffolding
118
+ if (init !== null && init !== undefined) {
119
+ // Project mode - scaffold a standalone project
120
+ await createProjectCommand({
121
+ path: init,
122
+ });
123
+ }
124
+ else {
125
+ // Check if we're inside a Town project
126
+ const projectRoot = await isInsideTownProject();
127
+ if (projectRoot === null) {
128
+ // Not in a project - prompt user to initialize
129
+ const answer = await inquirer.prompt([
130
+ {
131
+ type: "confirm",
132
+ name: "initProject",
133
+ message: "Not inside a Town project. Initialize project in current directory?",
134
+ default: true,
135
+ },
136
+ ]);
137
+ if (answer.initProject) {
138
+ // Initialize project first
139
+ await createProjectCommand({ path: process.cwd() });
140
+ // Then create agent
141
+ await createCommand({
142
+ ...(name !== undefined && { name }),
143
+ ...(model !== undefined && { model }),
144
+ ...(tools.length > 0 && { tools }),
145
+ ...(systemPrompt !== undefined && { systemPrompt }),
146
+ agentsDir: join(process.cwd(), "agents"),
147
+ });
148
+ }
149
+ else {
150
+ // User declined
151
+ console.log("\nPlease run 'town create' inside a project directory, or run:\n" +
152
+ " town create --init <path>\n" +
153
+ "to create a project.");
154
+ process.exit(1);
155
+ }
156
+ }
157
+ else {
158
+ // Agent mode - create agent in existing project
159
+ // Create command starts a long-running Ink session
160
+ await createCommand({
161
+ ...(name !== undefined && { name }),
162
+ ...(model !== undefined && { model }),
163
+ ...(tools.length > 0 && { tools }),
164
+ ...(systemPrompt !== undefined && { systemPrompt }),
165
+ agentsDir: join(projectRoot, "agents"),
166
+ });
167
+ }
168
+ }
121
169
  })
122
170
  .with("list", async () => {
123
171
  await listCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "town": "./dist/index.js"
@@ -18,16 +18,16 @@
18
18
  "build": "tsc"
19
19
  },
20
20
  "devDependencies": {
21
- "@townco/tsconfig": "0.1.13",
21
+ "@townco/tsconfig": "0.1.15",
22
22
  "@types/bun": "^1.3.1",
23
23
  "@types/react": "^19.2.2"
24
24
  },
25
25
  "dependencies": {
26
26
  "@optique/core": "^0.6.2",
27
27
  "@optique/run": "^0.6.2",
28
- "@townco/agent": "0.1.21",
29
- "@townco/secret": "0.1.16",
30
- "@townco/ui": "0.1.16",
28
+ "@townco/agent": "0.1.23",
29
+ "@townco/secret": "0.1.18",
30
+ "@townco/ui": "0.1.18",
31
31
  "@types/inquirer": "^9.0.9",
32
32
  "ink": "^6.4.0",
33
33
  "ink-text-input": "^6.0.0",