@townco/cli 0.1.2 → 0.1.4

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 @@
1
+ export declare function configureCommand(): Promise<void>;
@@ -0,0 +1,146 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import inquirer from "inquirer";
6
+ const ENV_KEYS = [
7
+ {
8
+ key: "ANTHROPIC_API_KEY",
9
+ description: "Anthropic API key for Claude models",
10
+ required: true,
11
+ },
12
+ {
13
+ key: "EXA_API_KEY",
14
+ description: "Exa API key for web search tool",
15
+ required: false,
16
+ },
17
+ {
18
+ key: "OPENAI_API_KEY",
19
+ description: "OpenAI API key (optional, for OpenAI models)",
20
+ required: false,
21
+ },
22
+ ];
23
+ function getConfigDir() {
24
+ return join(homedir(), ".config", "town");
25
+ }
26
+ function getEnvFilePath() {
27
+ return join(getConfigDir(), ".env");
28
+ }
29
+ async function loadExistingEnv() {
30
+ const envPath = getEnvFilePath();
31
+ if (!existsSync(envPath)) {
32
+ return {};
33
+ }
34
+ const content = await readFile(envPath, "utf-8");
35
+ const config = {};
36
+ for (const line of content.split("\n")) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed || trimmed.startsWith("#"))
39
+ continue;
40
+ const [key, ...valueParts] = trimmed.split("=");
41
+ const value = valueParts.join("=").trim();
42
+ if (key && value) {
43
+ config[key.trim()] = value;
44
+ }
45
+ }
46
+ return config;
47
+ }
48
+ async function saveEnv(config) {
49
+ const configDir = getConfigDir();
50
+ await mkdir(configDir, { recursive: true });
51
+ const lines = [
52
+ "# Town CLI Configuration",
53
+ "# Environment variables for Town agents",
54
+ "",
55
+ ];
56
+ for (const { key, description } of ENV_KEYS) {
57
+ const value = config[key];
58
+ if (value) {
59
+ lines.push(`# ${description}`);
60
+ lines.push(`${key}=${value}`);
61
+ lines.push("");
62
+ }
63
+ }
64
+ await writeFile(getEnvFilePath(), lines.join("\n"), "utf-8");
65
+ }
66
+ export async function configureCommand() {
67
+ console.log("🔧 Town Configuration\n");
68
+ const existingConfig = await loadExistingEnv();
69
+ const hasExisting = Object.keys(existingConfig).length > 0;
70
+ if (hasExisting) {
71
+ console.log("Found existing configuration:\n");
72
+ for (const { key, description } of ENV_KEYS) {
73
+ const value = existingConfig[key];
74
+ if (value) {
75
+ const masked = value.slice(0, 4) + "..." + value.slice(-4);
76
+ console.log(` ${key}: ${masked}`);
77
+ }
78
+ }
79
+ console.log("");
80
+ const { action } = await inquirer.prompt([
81
+ {
82
+ type: "list",
83
+ name: "action",
84
+ message: "What would you like to do?",
85
+ choices: [
86
+ { name: "Update existing keys", value: "update" },
87
+ { name: "Add new keys", value: "add" },
88
+ { name: "Reconfigure all keys", value: "reconfigure" },
89
+ { name: "Cancel", value: "cancel" },
90
+ ],
91
+ },
92
+ ]);
93
+ if (action === "cancel") {
94
+ console.log("Configuration cancelled.");
95
+ return;
96
+ }
97
+ if (action === "reconfigure") {
98
+ // Clear existing config for fresh start
99
+ Object.keys(existingConfig).forEach((key) => {
100
+ delete existingConfig[key];
101
+ });
102
+ }
103
+ }
104
+ const newConfig = { ...existingConfig };
105
+ // Prompt for each key
106
+ for (const { key, description, required } of ENV_KEYS) {
107
+ const hasValue = !!existingConfig[key];
108
+ if (hasValue && !hasExisting) {
109
+ // Skip if we're only adding new keys and this one exists
110
+ continue;
111
+ }
112
+ const { shouldSet } = await inquirer.prompt([
113
+ {
114
+ type: "confirm",
115
+ name: "shouldSet",
116
+ message: hasValue
117
+ ? `Update ${key}? (${description})`
118
+ : `Set ${key}? (${description})`,
119
+ default: required || !hasValue,
120
+ },
121
+ ]);
122
+ if (shouldSet) {
123
+ const { value } = await inquirer.prompt([
124
+ {
125
+ type: "password",
126
+ name: "value",
127
+ message: `Enter ${key}:`,
128
+ mask: "*",
129
+ validate: (input) => {
130
+ if (required && !input.trim()) {
131
+ return "This key is required";
132
+ }
133
+ return true;
134
+ },
135
+ },
136
+ ]);
137
+ if (value.trim()) {
138
+ newConfig[key] = value.trim();
139
+ }
140
+ }
141
+ }
142
+ // Save configuration
143
+ await saveEnv(newConfig);
144
+ console.log(`\n✅ Configuration saved to ${getEnvFilePath()}`);
145
+ console.log("\nThese environment variables will be automatically loaded when running agents.");
146
+ }
@@ -1,8 +1,37 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
2
5
  import { join } from "node:path";
3
6
  import { agentExists, getAgentPath } from "@townco/agent/storage";
7
+ async function loadEnvVars() {
8
+ const envPath = join(homedir(), ".config", "town", ".env");
9
+ const envVars = {};
10
+ if (!existsSync(envPath)) {
11
+ return envVars;
12
+ }
13
+ try {
14
+ const content = await readFile(envPath, "utf-8");
15
+ for (const line of content.split("\n")) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith("#"))
18
+ continue;
19
+ const [key, ...valueParts] = trimmed.split("=");
20
+ const value = valueParts.join("=").trim();
21
+ if (key && value) {
22
+ envVars[key.trim()] = value;
23
+ }
24
+ }
25
+ }
26
+ catch (error) {
27
+ console.warn(`Warning: Could not load environment variables from ${envPath}`);
28
+ }
29
+ return envVars;
30
+ }
4
31
  export async function runCommand(options) {
5
32
  const { name, http = false, gui = false, port = 3100 } = options;
33
+ // Load environment variables from ~/.config/town/.env
34
+ const configEnvVars = await loadEnvVars();
6
35
  // Check if agent exists
7
36
  const exists = await agentExists(name);
8
37
  if (!exists) {
@@ -35,6 +64,7 @@ export async function runCommand(options) {
35
64
  stdio: "pipe",
36
65
  env: {
37
66
  ...process.env,
67
+ ...configEnvVars,
38
68
  NODE_ENV: process.env.NODE_ENV || "production",
39
69
  PORT: port.toString(),
40
70
  },
@@ -51,6 +81,7 @@ export async function runCommand(options) {
51
81
  stdio: "inherit",
52
82
  env: {
53
83
  ...process.env,
84
+ ...configEnvVars,
54
85
  VITE_AGENT_URL: `http://localhost:${port}`,
55
86
  },
56
87
  });
@@ -87,6 +118,7 @@ export async function runCommand(options) {
87
118
  stdio: "inherit",
88
119
  env: {
89
120
  ...process.env,
121
+ ...configEnvVars,
90
122
  NODE_ENV: process.env.NODE_ENV || "production",
91
123
  PORT: port.toString(),
92
124
  },
@@ -105,12 +137,19 @@ export async function runCommand(options) {
105
137
  }
106
138
  // Default: Start TUI interface with the agent
107
139
  console.log(`Starting interactive terminal for agent "${name}"...\n`);
108
- // Run the agent in stdio mode directly
109
- const tuiProcess = spawn("bun", [binPath, "stdio"], {
140
+ // Get path to TUI app from CLI package
141
+ const { fileURLToPath } = await import("node:url");
142
+ const { dirname } = await import("node:path");
143
+ const currentFile = fileURLToPath(import.meta.url);
144
+ const cliRoot = join(dirname(currentFile), "..", "..");
145
+ const tuiPath = join(cliRoot, "tui", "index.js");
146
+ // Run TUI with the agent
147
+ const tuiProcess = spawn("bun", [tuiPath, "--agent", binPath], {
110
148
  cwd: agentPath,
111
149
  stdio: "inherit",
112
150
  env: {
113
151
  ...process.env,
152
+ ...configEnvVars,
114
153
  NODE_ENV: process.env.NODE_ENV || "production",
115
154
  },
116
155
  });
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { run } from "@optique/run";
6
6
  import { createSecret, deleteSecret, genenv, listSecrets } from "@townco/secret";
7
7
  import inquirer from "inquirer";
8
8
  import { match } from "ts-pattern";
9
+ import { configureCommand } from "./commands/configure.js";
9
10
  import { createCommand } from "./commands/create.js";
10
11
  import { deleteCommand } from "./commands/delete.js";
11
12
  import { editCommand } from "./commands/edit.js";
@@ -25,7 +26,9 @@ async function promptSecret(secretName) {
25
26
  ]);
26
27
  return answers.value;
27
28
  }
28
- const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy a Town.` }), command("create", object({
29
+ const parser = or(command("deploy", constant("deploy"), { brief: message `Deploy a Town.` }), command("configure", constant("configure"), {
30
+ brief: message `Configure environment variables.`,
31
+ }), command("create", object({
29
32
  command: constant("create"),
30
33
  name: optional(option("-n", "--name", string())),
31
34
  model: optional(option("-m", "--model", string())),
@@ -69,6 +72,9 @@ async function main(parser, meta) {
69
72
  await match(result)
70
73
  // TODO
71
74
  .with("deploy", async () => { })
75
+ .with("configure", async () => {
76
+ await configureCommand();
77
+ })
72
78
  .with({ command: "create" }, async ({ name, model, tools, systemPrompt }) => {
73
79
  // Create command starts a long-running Ink session
74
80
  // Only pass defined properties to satisfy exactOptionalPropertyTypes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "tui",
12
13
  "README.md"
13
14
  ],
14
15
  "repository": {
package/tui/App.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { CliConfig } from "./cli.js";
2
+ export interface AppProps {
3
+ config: CliConfig;
4
+ }
5
+ export declare function App({ config }: AppProps): import("react/jsx-runtime").JSX.Element;
package/tui/App.js ADDED
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AcpClient } from "@townco/ui";
3
+ import { ChatView } from "@townco/ui/tui";
4
+ import { Box, Text } from "ink";
5
+ import { useMemo } from "react";
6
+ export function App({ config }) {
7
+ // Create ACP client
8
+ const client = useMemo(() => {
9
+ try {
10
+ const newClient = new AcpClient({
11
+ type: "stdio",
12
+ options: {
13
+ agentPath: config.agentPath,
14
+ workingDirectory: config.workingDir ?? process.cwd(),
15
+ },
16
+ });
17
+ return newClient;
18
+ }
19
+ catch (error) {
20
+ console.error("Failed to create client:", error);
21
+ return null;
22
+ }
23
+ }, [config.agentPath, config.workingDir]);
24
+ if (!client) {
25
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Error: Failed to create ACP client" }), _jsx(Text, { color: "gray", children: "Please check your agent path and try again." })] }));
26
+ }
27
+ return _jsx(ChatView, { client: client });
28
+ }
package/tui/cli.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { InferValue } from "@optique/core/parser";
2
+ /**
3
+ * CLI configuration using Optique
4
+ */
5
+ export declare const cliParser: import("@optique/core/parser").Parser<{
6
+ readonly agentPath: string;
7
+ readonly workingDir: string | undefined;
8
+ }, {
9
+ readonly agentPath: import("@optique/core/valueparser").ValueParserResult<string> | undefined;
10
+ readonly workingDir: [[import("@optique/core/valueparser").ValueParserResult<string> | undefined] | undefined] | undefined;
11
+ }>;
12
+ export type CliConfig = InferValue<typeof cliParser>;
package/tui/cli.js ADDED
@@ -0,0 +1,14 @@
1
+ import { text } from "@optique/core/message";
2
+ import { object, option, optional, withDefault } from "@optique/core/parser";
3
+ import { string } from "@optique/core/valueparser";
4
+ /**
5
+ * CLI configuration using Optique
6
+ */
7
+ export const cliParser = object({
8
+ agentPath: option("-a", "--agent", string(), {
9
+ description: [text("Path to the ACP agent executable")],
10
+ }),
11
+ workingDir: withDefault(optional(option("-d", "--dir", string(), {
12
+ description: [text("Working directory for the agent")],
13
+ })), process.cwd()),
14
+ });
package/tui/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/tui/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { run } from "@optique/run";
4
+ import { render } from "ink";
5
+ import { App } from "./App.js";
6
+ import { cliParser } from "./cli.js";
7
+ /**
8
+ * Agent Hub TUI
9
+ * Terminal chat interface for ACP agents
10
+ */
11
+ async function main() {
12
+ // Parse CLI arguments
13
+ const config = run(cliParser, {
14
+ help: "both", // Show help for both commands and the program
15
+ programName: "agent-tui",
16
+ description: [
17
+ {
18
+ type: "text",
19
+ text: "Terminal chat interface for Agent Client Protocol (ACP) agents",
20
+ },
21
+ ],
22
+ version: "0.0.1",
23
+ });
24
+ // Render the app
25
+ render(_jsx(App, { config: config }));
26
+ }
27
+ main().catch((error) => {
28
+ console.error("Fatal error:", error);
29
+ process.exit(1);
30
+ });