@smithers-orchestrator/cli 0.16.8 → 0.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/cli",
3
- "version": "0.16.8",
3
+ "version": "0.17.0",
4
4
  "description": "Smithers command-line interface, TUI, MCP server, and local workflow tools",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,32 +20,41 @@
20
20
  "src/"
21
21
  ],
22
22
  "dependencies": {
23
+ "@mdx-js/esbuild": "^3.1.1",
23
24
  "@effect/workflow": "^0.18.0",
25
+ "@clack/prompts": "^0.10.1",
24
26
  "@modelcontextprotocol/sdk": "^1.29.0",
25
27
  "@opentui/core": "^0.1.100",
26
28
  "@opentui/react": "^0.1.100",
29
+ "cron-parser": "^5.5.0",
30
+ "drizzle-orm": "^0.45.2",
27
31
  "effect": "^3.21.1",
28
32
  "incur": "^0.4.1",
29
33
  "picocolors": "^1.1.1",
30
34
  "react": "^19.2.5",
31
35
  "zod": "^4.3.6",
32
- "@smithers-orchestrator/components": "0.16.8",
33
- "@smithers-orchestrator/agents": "0.16.8",
34
- "@smithers-orchestrator/observability": "0.16.8",
35
- "@smithers-orchestrator/driver": "0.16.8",
36
- "@smithers-orchestrator/db": "0.16.8",
37
- "@smithers-orchestrator/errors": "0.16.8",
38
- "@smithers-orchestrator/scheduler": "0.16.8",
39
- "@smithers-orchestrator/time-travel": "0.16.8",
40
- "@smithers-orchestrator/server": "0.16.8",
41
- "smithers-orchestrator": "0.16.8"
36
+ "@smithers-orchestrator/agents": "0.17.0",
37
+ "@smithers-orchestrator/accounts": "0.17.0",
38
+ "@smithers-orchestrator/components": "0.17.0",
39
+ "@smithers-orchestrator/db": "0.17.0",
40
+ "@smithers-orchestrator/devtools": "0.17.0",
41
+ "@smithers-orchestrator/memory": "0.17.0",
42
+ "@smithers-orchestrator/driver": "0.17.0",
43
+ "@smithers-orchestrator/errors": "0.17.0",
44
+ "@smithers-orchestrator/engine": "0.17.0",
45
+ "@smithers-orchestrator/observability": "0.17.0",
46
+ "@smithers-orchestrator/protocol": "0.17.0",
47
+ "@smithers-orchestrator/openapi": "0.17.0",
48
+ "@smithers-orchestrator/scheduler": "0.17.0",
49
+ "@smithers-orchestrator/time-travel": "0.17.0",
50
+ "@smithers-orchestrator/server": "0.17.0"
42
51
  },
43
52
  "devDependencies": {
44
53
  "@types/bun": "latest",
45
54
  "typescript": "~5.9.3"
46
55
  },
47
56
  "scripts": {
48
- "test": "bun test tests",
57
+ "test": "bun test --timeout=60000 --max-concurrency=1 tests",
49
58
  "typecheck": "tsc -p tsconfig.json --noEmit",
50
59
  "build": "tsup --dts-only"
51
60
  }
@@ -1,4 +1,6 @@
1
1
  export type InitWorkflowPackOptions = {
2
+ agentsOnly?: boolean;
2
3
  force?: boolean;
3
4
  rootDir?: string;
5
+ skipInstall?: boolean;
4
6
  };
@@ -1,6 +1,12 @@
1
+ type InitInstallResult = {
2
+ reason?: string;
3
+ status: "failed" | "ok" | "skipped";
4
+ };
5
+
1
6
  export type InitWorkflowPackResult = {
7
+ install: InitInstallResult;
8
+ preservedPaths: string[];
2
9
  rootDir: string;
3
- writtenFiles: string[];
4
10
  skippedFiles: string[];
5
- preservedPaths: string[];
11
+ writtenFiles: string[];
6
12
  };
@@ -0,0 +1,190 @@
1
+ import { confirm, intro, isCancel, note, outro, password, select, spinner, text } from "@clack/prompts";
2
+ import { defaultConfigDir } from "@smithers-orchestrator/accounts";
3
+ import { runAgentAdd, pingAccount } from "./runAgentAdd.js";
4
+
5
+ /** @typedef {import("@smithers-orchestrator/accounts").AccountProvider} AccountProvider */
6
+
7
+ const PROVIDER_CHOICES = [
8
+ { value: "claude-code", label: "Claude Code (subscription)", hint: "Pro / Max plan via `claude` CLI" },
9
+ { value: "codex", label: "Codex (subscription)", hint: "ChatGPT Plus/Pro via `codex` CLI" },
10
+ { value: "gemini", label: "Gemini (subscription)", hint: "Google account via `gemini` CLI" },
11
+ { value: "kimi", label: "Kimi (subscription)", hint: "OAuth via `kimi` CLI" },
12
+ { value: "anthropic-api", label: "Anthropic API key", hint: "Pay-per-token via api.anthropic.com" },
13
+ { value: "openai-api", label: "OpenAI API key", hint: "Pay-per-token via api.openai.com (used by Codex)" },
14
+ { value: "gemini-api", label: "Gemini API key", hint: "Pay-per-token via Google AI Studio" },
15
+ ];
16
+
17
+ const SUBSCRIPTION_LOGIN_BIN = {
18
+ "claude-code": "claude",
19
+ "codex": "codex",
20
+ "gemini": "gemini",
21
+ "kimi": "kimi",
22
+ };
23
+
24
+ const SUBSCRIPTION_DIR_ENV_VAR = {
25
+ "claude-code": "CLAUDE_CONFIG_DIR",
26
+ "codex": "CODEX_HOME",
27
+ "gemini": "GEMINI_DIR",
28
+ "kimi": "KIMI_SHARE_DIR",
29
+ };
30
+
31
+ /**
32
+ * Provider-specific login command + on-screen instructions. Some CLIs use a
33
+ * dedicated subcommand (`codex login`, `kimi login`); others authenticate via
34
+ * a slash command inside the REPL (`claude` then /login).
35
+ *
36
+ * @type {Record<string, { args: string[]; postInstructions?: string }>}
37
+ */
38
+ const SUBSCRIPTION_LOGIN_RECIPE = {
39
+ "claude-code": { args: [], postInstructions: "Inside Claude Code, type /login and follow the browser flow." },
40
+ "codex": { args: ["login"] },
41
+ "gemini": { args: [], postInstructions: "Inside Gemini, type /auth (or follow the prompt) to sign in." },
42
+ "kimi": { args: ["login"] },
43
+ };
44
+
45
+ function bail() {
46
+ outro("Cancelled.");
47
+ process.exit(130);
48
+ }
49
+
50
+ /**
51
+ * Interactive `smithers agents add` wizard. Loops until the user is done
52
+ * adding accounts. Returns the list of labels that were added in this session.
53
+ *
54
+ * @param {{ env?: NodeJS.ProcessEnv; cwd?: string; loop?: boolean; skipIntro?: boolean }} [opts]
55
+ * @returns {Promise<string[]>}
56
+ */
57
+ export async function agentAddWizard(opts = {}) {
58
+ const env = opts.env ?? process.env;
59
+ const cwd = opts.cwd ?? process.cwd();
60
+ if (!opts.skipIntro) intro("Add a Smithers agent account");
61
+ /** @type {string[]} */
62
+ const added = [];
63
+ while (true) {
64
+ const provider = await select({
65
+ message: "Which provider?",
66
+ options: PROVIDER_CHOICES,
67
+ });
68
+ if (isCancel(provider)) bail();
69
+ const label = await text({
70
+ message: "Label this account",
71
+ placeholder: provider === "claude-code" ? "claude-work" : `${provider}-1`,
72
+ validate(value) {
73
+ if (!value || !value.trim()) return "Label cannot be empty";
74
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) return "Use letters, digits, '.', '_' or '-'";
75
+ },
76
+ });
77
+ if (isCancel(label)) bail();
78
+ const isSubscription = SUBSCRIPTION_LOGIN_BIN[provider] !== undefined;
79
+ /** @type {string | undefined} */
80
+ let configDir;
81
+ /** @type {string | undefined} */
82
+ let apiKey;
83
+ if (isSubscription) {
84
+ const useDefault = await confirm({
85
+ message: `Store credentials at the default location (~/.smithers/accounts/${label})?`,
86
+ initialValue: true,
87
+ });
88
+ if (isCancel(useDefault)) bail();
89
+ if (useDefault) {
90
+ configDir = defaultConfigDir(label, env);
91
+ }
92
+ else {
93
+ const customDir = await text({
94
+ message: "Path to existing CLI config dir (e.g. ~/.claude or ~/.codex)",
95
+ validate(value) {
96
+ if (!value || !value.trim()) return "Path cannot be empty";
97
+ },
98
+ });
99
+ if (isCancel(customDir)) bail();
100
+ configDir = customDir.replace(/^~(?=\/|$)/, env.HOME ?? "");
101
+ }
102
+ const bin = SUBSCRIPTION_LOGIN_BIN[provider];
103
+ const envVar = SUBSCRIPTION_DIR_ENV_VAR[provider];
104
+ const recipe = SUBSCRIPTION_LOGIN_RECIPE[provider] ?? { args: [] };
105
+ const loginCmd = `${envVar}=${configDir} ${bin}${recipe.args.length ? " " + recipe.args.join(" ") : ""}`;
106
+ const lines = [
107
+ "Open another terminal and run:",
108
+ "",
109
+ ` ${loginCmd}`,
110
+ ];
111
+ if (recipe.postInstructions) {
112
+ lines.push("", recipe.postInstructions);
113
+ }
114
+ lines.push("", "Come back here once you're done.");
115
+ note(lines.join("\n"), "Log in");
116
+ const ready = await confirm({
117
+ message: "Logged in?",
118
+ initialValue: true,
119
+ });
120
+ if (isCancel(ready)) bail();
121
+ // Trust the user: if they say they logged in, register without
122
+ // re-checking the dir. We surface success/failure via a ping below.
123
+ const sp = spinner();
124
+ sp.start("Registering account…");
125
+ const result = runAgentAdd({
126
+ provider: /** @type {AccountProvider} */ (provider),
127
+ label,
128
+ configDir,
129
+ env,
130
+ cwd,
131
+ skipLogin: true,
132
+ });
133
+ if (!result.ok) {
134
+ sp.stop(`Could not register: ${result.reason}`);
135
+ note(result.detail ?? "");
136
+ }
137
+ else {
138
+ sp.stop(`Registered ${result.account.label} (${result.account.provider}).`);
139
+ added.push(result.account.label);
140
+ if (result.regen?.rewritten) note(`Updated ${result.regen.path}`, ".smithers/agents.ts");
141
+ if (ready) {
142
+ const ping = pingAccount(result.account);
143
+ if (ping.ran) {
144
+ const status = ping.exitCode === 0 ? "OK" : `non-zero exit (${ping.exitCode ?? "?"})`;
145
+ note(`${ping.cmd}\n→ ${status}`, "Ping");
146
+ }
147
+ }
148
+ else {
149
+ note(`Registered without verifying. Run \`smithers agents test ${result.account.label}\` after logging in.`);
150
+ }
151
+ }
152
+ }
153
+ else {
154
+ const key = await password({
155
+ message: "Paste your API key (kept locally in ~/.smithers/accounts.json, mode 0600)",
156
+ validate(value) {
157
+ if (!value) return "API key cannot be empty";
158
+ },
159
+ });
160
+ if (isCancel(key)) bail();
161
+ apiKey = key;
162
+ const sp = spinner();
163
+ sp.start("Registering account…");
164
+ const result = runAgentAdd({
165
+ provider: /** @type {AccountProvider} */ (provider),
166
+ label,
167
+ apiKey,
168
+ env,
169
+ cwd,
170
+ });
171
+ if (!result.ok) {
172
+ sp.stop(`Could not register: ${result.reason}`);
173
+ note(result.detail ?? "");
174
+ }
175
+ else {
176
+ sp.stop(`Registered ${result.account.label} (${result.account.provider}).`);
177
+ added.push(result.account.label);
178
+ if (result.regen?.rewritten) note(`Updated ${result.regen.path}`, ".smithers/agents.ts");
179
+ }
180
+ }
181
+ if (!opts.loop) break;
182
+ const another = await confirm({
183
+ message: "Add another account?",
184
+ initialValue: false,
185
+ });
186
+ if (isCancel(another) || !another) break;
187
+ }
188
+ if (!opts.skipIntro) outro(`Added ${added.length} account(s).`);
189
+ return added;
190
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { generateAgentsTs } from "../agent-detection.js";
4
+
5
+ /**
6
+ * If the current working directory contains a `.smithers/agents.ts` that was
7
+ * previously generated by smithers (sentinel comment present), regenerate it
8
+ * to reflect the current accounts registry. User-edited files are left alone.
9
+ *
10
+ * @param {string} [cwd]
11
+ * @returns {{ rewritten: boolean; path: string | null; reason?: string }}
12
+ */
13
+ export function regenerateAgentsTsIfPresent(cwd = process.cwd()) {
14
+ const path = join(cwd, ".smithers", "agents.ts");
15
+ if (!existsSync(path)) {
16
+ return { rewritten: false, path: null, reason: "no .smithers/agents.ts in cwd" };
17
+ }
18
+ const existing = readFileSync(path, "utf8");
19
+ if (!existing.startsWith("// smithers-source: generated")) {
20
+ return { rewritten: false, path, reason: "agents.ts has been edited by hand; not overwriting" };
21
+ }
22
+ const next = generateAgentsTs(process.env);
23
+ if (next === existing) {
24
+ return { rewritten: false, path, reason: "no changes" };
25
+ }
26
+ writeFileSync(path, next, "utf8");
27
+ return { rewritten: true, path };
28
+ }
@@ -0,0 +1,147 @@
1
+ import { mkdirSync, existsSync, readdirSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { addAccount, defaultConfigDir } from "@smithers-orchestrator/accounts";
4
+ import { regenerateAgentsTsIfPresent } from "./regenerateAgentsTsIfPresent.js";
5
+
6
+ /** @typedef {import("@smithers-orchestrator/accounts").AccountProvider} AccountProvider */
7
+
8
+ /**
9
+ * Provider id → CLI binary name. For API-key providers this is null because
10
+ * they don't have a separate CLI to log into.
11
+ * @type {Record<string, string | null>}
12
+ */
13
+ const SUBSCRIPTION_LOGIN_BIN = {
14
+ "claude-code": "claude",
15
+ "codex": "codex",
16
+ "gemini": "gemini",
17
+ "kimi": "kimi",
18
+ "anthropic-api": null,
19
+ "openai-api": null,
20
+ "gemini-api": null,
21
+ };
22
+
23
+ /**
24
+ * Subcommand args appended to the login command. Some CLIs use a dedicated
25
+ * subcommand (`codex login`, `kimi login`); others authenticate via a slash
26
+ * command inside the REPL (the user types /login after launching).
27
+ * @type {Record<string, string[]>}
28
+ */
29
+ const SUBSCRIPTION_LOGIN_ARGS = {
30
+ "claude-code": [],
31
+ "codex": ["login"],
32
+ "gemini": [],
33
+ "kimi": ["login"],
34
+ };
35
+
36
+ /**
37
+ * Provider id → env var the CLI reads to find its config dir.
38
+ * @type {Record<string, string | null>}
39
+ */
40
+ const SUBSCRIPTION_DIR_ENV_VAR = {
41
+ "claude-code": "CLAUDE_CONFIG_DIR",
42
+ "codex": "CODEX_HOME",
43
+ "gemini": "GEMINI_DIR",
44
+ "kimi": "KIMI_SHARE_DIR",
45
+ "anthropic-api": null,
46
+ "openai-api": null,
47
+ "gemini-api": null,
48
+ };
49
+
50
+ /**
51
+ * @param {string} dir
52
+ * @returns {boolean}
53
+ */
54
+ function dirHasContents(dir) {
55
+ if (!existsSync(dir)) return false;
56
+ try { return readdirSync(dir).length > 0; }
57
+ catch { return false; }
58
+ }
59
+
60
+ /**
61
+ * @typedef {{
62
+ * provider: AccountProvider;
63
+ * label: string;
64
+ * configDir?: string;
65
+ * apiKey?: string;
66
+ * model?: string;
67
+ * skipLogin?: boolean;
68
+ * force?: boolean;
69
+ * replace?: boolean;
70
+ * env?: NodeJS.ProcessEnv;
71
+ * cwd?: string;
72
+ * loginInstructions?: (cmd: string) => void;
73
+ * }} RunAgentAddInput
74
+ */
75
+
76
+ /**
77
+ * Non-interactive entry point: register an account from already-resolved
78
+ * inputs. Used by both the flag-driven CLI and the clack wizard. Returns the
79
+ * persisted account plus a summary of the register/regen operation.
80
+ *
81
+ * @param {RunAgentAddInput} input
82
+ */
83
+ export function runAgentAdd(input) {
84
+ const env = input.env ?? process.env;
85
+ const cwd = input.cwd ?? process.cwd();
86
+ const isSubscription = SUBSCRIPTION_LOGIN_BIN[input.provider] !== null;
87
+ const isApiKey = !isSubscription;
88
+ if (isSubscription) {
89
+ const configDir = input.configDir ?? defaultConfigDir(input.label, env);
90
+ mkdirSync(configDir, { recursive: true });
91
+ // Verify there's something there before registering, unless --force or
92
+ // --skip-login (e2e tests pre-populate a fake credentials file).
93
+ const populated = dirHasContents(configDir);
94
+ if (!populated && !input.skipLogin && !input.force) {
95
+ const bin = SUBSCRIPTION_LOGIN_BIN[input.provider];
96
+ const envVar = SUBSCRIPTION_DIR_ENV_VAR[input.provider];
97
+ const subArgs = SUBSCRIPTION_LOGIN_ARGS[input.provider] ?? [];
98
+ const cmd = `${envVar}=${configDir} ${bin}${subArgs.length ? " " + subArgs.join(" ") : ""}`;
99
+ const detail = `Config dir ${configDir} is empty. Run the following in another terminal to log in, then re-run \`smithers agents add\`:\n\n ${cmd}\n\n(or pass --skip-login to register the empty dir, --force to register without verification)`;
100
+ return { ok: false, reason: "login-required", detail, configDir };
101
+ }
102
+ const account = addAccount({
103
+ label: input.label,
104
+ provider: input.provider,
105
+ configDir,
106
+ model: input.model,
107
+ }, { env, replace: input.replace });
108
+ const regen = regenerateAgentsTsIfPresent(cwd);
109
+ return { ok: true, account, regen };
110
+ }
111
+ if (isApiKey) {
112
+ if (typeof input.apiKey !== "string") {
113
+ return { ok: false, reason: "missing-api-key", detail: `Provider ${input.provider} requires --api-key (may be empty for env-var-only).` };
114
+ }
115
+ const account = addAccount({
116
+ label: input.label,
117
+ provider: input.provider,
118
+ apiKey: input.apiKey,
119
+ model: input.model,
120
+ }, { env, replace: input.replace });
121
+ const regen = regenerateAgentsTsIfPresent(cwd);
122
+ return { ok: true, account, regen };
123
+ }
124
+ return { ok: false, reason: "unknown-provider", detail: `Unknown provider: ${input.provider}` };
125
+ }
126
+
127
+ /**
128
+ * Quick health check: spawn `<bin> --version` (or equivalent) under the
129
+ * account's env vars and report whether the CLI starts cleanly. Best-effort —
130
+ * a non-zero exit is reported but does not throw.
131
+ *
132
+ * @param {{ provider: AccountProvider; configDir?: string; apiKey?: string }} account
133
+ * @returns {{ ran: boolean; exitCode: number | null; cmd: string }}
134
+ */
135
+ export function pingAccount(account) {
136
+ const bin = SUBSCRIPTION_LOGIN_BIN[account.provider];
137
+ if (!bin) return { ran: false, exitCode: null, cmd: "(api-key provider; no CLI to ping)" };
138
+ const envVar = SUBSCRIPTION_DIR_ENV_VAR[account.provider];
139
+ const env = { ...process.env };
140
+ if (envVar && account.configDir) env[envVar] = account.configDir;
141
+ const result = spawnSync(bin, ["--version"], { env, encoding: "utf8" });
142
+ return {
143
+ ran: true,
144
+ exitCode: result.status,
145
+ cmd: `${envVar ? `${envVar}=${account.configDir} ` : ""}${bin} --version`,
146
+ };
147
+ }