symbiot 0.2.0 → 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +44 -17
  2. package/index.mjs +391 -113
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -4,18 +4,36 @@ Your week, written from your real work.
4
4
 
5
5
  Symbiot reads your **local git activity** and writes the update you'd actually send —
6
6
  your weekly summary, your standup, or what's still on your plate. No accounts, no
7
- OAuth, no integrations to wire up. The only thing it needs is an AI key, which you
8
- bring yourself.
7
+ OAuth, no integrations to wire up. It writes with the **AI of your choice** —
8
+ Claude, OpenAI, Gemini, or a **local model** via Ollama — set up once.
9
9
 
10
10
  ```
11
11
  symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
- symbiot login connect it to Claude (once)
14
+ symbiot app open the visual app in your browser
15
+ symbiot login connect it to an AI (once)
15
16
  symbiot whoami show how it's connected
16
17
  symbiot help
17
18
  ```
18
19
 
20
+ ## Prefer a window? `symbiot app`
21
+
22
+ ```bash
23
+ symbiot app
24
+ ```
25
+
26
+ Starts a tiny local server (127.0.0.1 only, protected by a one-time token) and
27
+ opens the visual app in your browser — in a clean, chrome-less window if you have
28
+ Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. Tabs for
29
+ Week / Standup / Todo, a Copy button, and a Settings panel to pick your AI. No
30
+ Electron, no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
31
+
32
+ **Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
33
+ and UI are just a local web page. The chrome-less window is detected per-OS
34
+ (PATH on Linux, the `/Applications` bundle on macOS, `Program Files` on Windows);
35
+ where no Chromium-family browser is found it opens your default browser instead.
36
+
19
37
  ## Install
20
38
 
21
39
  ```bash
@@ -24,16 +42,25 @@ npm install -g symbiot # or run without installing: npx symbiot week
24
42
 
25
43
  ## Connect it (once)
26
44
 
27
- Symbiot writes with **Claude**, so it needs an Anthropic API key. The easiest way:
45
+ Pick the AI you want it to write with:
28
46
 
29
47
  ```bash
30
- symbiot login # paste a key once — saved locally to ~/.config/symbiot
48
+ symbiot login # choose Claude, OpenAI, Gemini, or a local model (Ollama)
31
49
  ```
32
50
 
33
- Get a key at <https://console.anthropic.com/settings/keys>. Prefer an env var? Set
34
- `ANTHROPIC_API_KEY=sk-ant-...` instead and skip `login`. If you already use the
35
- Anthropic CLI, `ant auth login` works too — Symbiot picks up any of these
36
- automatically, and `symbiot whoami` shows which one it's using.
51
+ You'll get a short menu; paste that provider's API key (or, for Ollama, just point
52
+ it at your local server) and it's saved to `~/.config/symbiot/config.json`.
53
+
54
+ | Provider | Get a key | Default model |
55
+ |----------|-----------|---------------|
56
+ | Claude (Anthropic) | <https://console.anthropic.com/settings/keys> | `claude-opus-5-5` |
57
+ | OpenAI (GPT) | <https://platform.openai.com/api-keys> | `gpt-4o-mini` |
58
+ | Gemini (Google) | <https://aistudio.google.com/apikey> | `gemini-1.5-flash` |
59
+ | Local (Ollama) | runs on your machine, no key | `llama3.1` |
60
+
61
+ Non-interactive: `symbiot login --provider openai --key sk-... [--model gpt-4o]`.
62
+ Environment keys are auto-detected too (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
63
+ `GEMINI_API_KEY`), and `symbiot whoami` shows which one is active.
37
64
 
38
65
  ## Use
39
66
 
@@ -55,20 +82,20 @@ Point it somewhere specific with `--dir ~/work`.
55
82
 
56
83
  - **Reads:** your local git — commit messages and changed-file names, plus
57
84
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
58
- - **Sends to the model:** only those commit/TODO summaries, so it can write the
59
- update. Not your code, not file contents.
85
+ - **Sends to the AI:** only those commit/TODO summaries, so it can write the
86
+ update. Not your code, not file contents. With a **local Ollama model,
87
+ nothing leaves your machine at all.**
60
88
  - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
61
89
 
62
90
  ## Config
63
91
 
64
- - `SYMBIOT_MODEL` — the model to use (default `claude-opus-5-5`; `claude-haiku-4-5`
65
- is a cheaper option for this kind of summary).
66
- - `ANTHROPIC_API_KEY` — your key. Or run `symbiot login` once, or use an
67
- `ant auth login` profile. `symbiot whoami` shows which credential is active;
68
- `symbiot logout` forgets a saved key.
92
+ - `SYMBIOT_MODEL` — override the model for any provider (e.g. `gpt-4o`,
93
+ `claude-haiku-4-5`, `gemini-1.5-pro`).
94
+ - `symbiot whoami` shows the active provider, model, and where the credential
95
+ came from. `symbiot logout` forgets saved credentials.
69
96
 
70
97
  ---
71
98
 
72
99
  Part of Symbiot — an assistant that learns how you work and gives it back to you.
73
100
  This CLI is the personal, install-it-yourself front door: it needs nothing but your
74
- own machine and one key.
101
+ own machine and an AI of your choice.
package/index.mjs CHANGED
@@ -2,13 +2,14 @@
2
2
  // symbiot — your week, written from your real work.
3
3
  //
4
4
  // Reads your local git activity (no accounts, no OAuth, no integrations) and
5
- // writes the update you'd actually send. It writes with Claude, so it needs an
6
- // Anthropic API key — set one up once with `symbiot login`.
5
+ // writes the update you'd actually send. It writes with an AI of your choice —
6
+ // Claude, OpenAI, Gemini, or a local model (Ollama) — set up once with
7
+ // `symbiot login`.
7
8
  //
8
9
  // symbiot week your week, written up (default)
9
10
  // symbiot standup yesterday + today, for standup
10
11
  // symbiot todo what's still on your plate
11
- // symbiot login connect it to Claude (once)
12
+ // symbiot login connect it to an AI (once)
12
13
  // symbiot whoami show how it's connected
13
14
  // symbiot help
14
15
  //
@@ -17,18 +18,29 @@
17
18
  // --plain no colour, no spinner (for piping)
18
19
 
19
20
  import Anthropic from "@anthropic-ai/sdk";
20
- import { execSync } from "node:child_process";
21
+ import { execSync, spawn } from "node:child_process";
21
22
  import { homedir } from "node:os";
22
23
  import { join } from "node:path";
23
24
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
24
25
  import { createInterface } from "node:readline";
26
+ import { createServer } from "node:http";
27
+ import { randomBytes } from "node:crypto";
25
28
 
26
- const MODEL = process.env.SYMBIOT_MODEL || "claude-opus-5-5";
27
29
  const MAX_REPOS = 14;
28
30
  const MAX_COMMITS = 140;
31
+ const MAX_TOKENS = 1600;
29
32
  const CONFIG_DIR = join(homedir(), ".config", "symbiot");
30
33
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
31
34
 
35
+ // The providers Symbiot can write with. Models are sensible defaults; override
36
+ // per provider at login, or globally with SYMBIOT_MODEL.
37
+ const PROVIDERS = {
38
+ anthropic: { label: "Claude (Anthropic)", env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], keyUrl: "https://console.anthropic.com/settings/keys", keyName: "Anthropic API key (sk-ant-…)", model: "claude-opus-5-5" },
39
+ openai: { label: "OpenAI (GPT)", env: ["OPENAI_API_KEY"], keyUrl: "https://platform.openai.com/api-keys", keyName: "OpenAI API key (sk-…)", model: "gpt-4o-mini" },
40
+ gemini: { label: "Gemini (Google)", env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], keyUrl: "https://aistudio.google.com/apikey", keyName: "Google AI API key", model: "gemini-1.5-flash" },
41
+ ollama: { label: "Local model (Ollama)", local: true, keyUrl: "https://ollama.com", keyName: null, model: "llama3.1" },
42
+ };
43
+
32
44
  // ---- tiny arg parse --------------------------------------------------------
33
45
  const argv = process.argv.slice(2);
34
46
  const cmd = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "week").toLowerCase();
@@ -38,6 +50,7 @@ const flag = (name, def) => {
38
50
  };
39
51
  const has = (name) => argv.includes(`--${name}`);
40
52
  const PLAIN = has("plain") || !process.stdout.isTTY;
53
+ let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
41
54
 
42
55
  // ---- colour + spinner ------------------------------------------------------
43
56
  const c = PLAIN
@@ -49,6 +62,7 @@ const c = PLAIN
49
62
  y: (s) => `\x1b[38;5;179m${s}\x1b[0m`, // amber
50
63
  };
51
64
  function spinner(label) {
65
+ if (SERVING) return () => {};
52
66
  if (PLAIN) { process.stderr.write(label + "\n"); return () => {}; }
53
67
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
54
68
  let i = 0;
@@ -56,11 +70,7 @@ function spinner(label) {
56
70
  return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
57
71
  }
58
72
 
59
- // ---- auth ------------------------------------------------------------------
60
- // Symbiot writes with Claude. Credentials can come from (first wins):
61
- // 1. ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN in the environment
62
- // 2. a key saved by `symbiot login` (~/.config/symbiot/config.json)
63
- // 3. an Anthropic CLI profile on disk (`ant auth login`), which the SDK reads
73
+ // ---- config + provider resolution -----------------------------------------
64
74
  function loadConfig() {
65
75
  try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
66
76
  }
@@ -75,20 +85,28 @@ function saveConfig(cfg) {
75
85
  function antProfileExists() {
76
86
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
77
87
  }
78
- // Returns { opts, source } for building the client, or null if nothing is set up.
79
- function resolveAuth() {
80
- if (process.env.ANTHROPIC_API_KEY) return { opts: {}, source: "ANTHROPIC_API_KEY (environment)" };
81
- if (process.env.ANTHROPIC_AUTH_TOKEN) return { opts: {}, source: "ANTHROPIC_AUTH_TOKEN (environment)" };
82
- const cfg = loadConfig();
83
- if (cfg.apiKey) return { opts: { apiKey: cfg.apiKey }, source: "your saved login (~/.config/symbiot)" };
84
- if (antProfileExists()) return { opts: {}, source: "your Anthropic CLI login (ant auth login)" };
88
+ function envKey(provider) {
89
+ for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
85
90
  return null;
86
91
  }
87
- function getClient() {
88
- const auth = resolveAuth();
89
- if (!auth) return null;
90
- try { return { client: new Anthropic(auth.opts), source: auth.source }; }
91
- catch { return null; }
92
+ // Returns { provider, key?, baseUrl?, model, source } or null if nothing set up.
93
+ // Order: saved choice → legacy saved key → env keys → an `ant` profile.
94
+ function resolveProvider() {
95
+ const cfg = loadConfig();
96
+ const m = process.env.SYMBIOT_MODEL;
97
+ if (cfg.provider && PROVIDERS[cfg.provider]) {
98
+ const p = cfg.provider, pc = cfg[p] || {};
99
+ if (p === "ollama") return { provider: p, baseUrl: pc.baseUrl || "http://localhost:11434", model: m || pc.model || PROVIDERS.ollama.model, source: "saved login" };
100
+ const key = pc.apiKey || envKey(p);
101
+ if (key || (p === "anthropic" && (process.env.ANTHROPIC_AUTH_TOKEN || antProfileExists())))
102
+ return { provider: p, key, model: m || pc.model || PROVIDERS[p].model, source: pc.apiKey ? "saved login" : "environment" };
103
+ }
104
+ if (cfg.apiKey) return { provider: "anthropic", key: cfg.apiKey, model: m || PROVIDERS.anthropic.model, source: "saved login (~/.config/symbiot)" };
105
+ if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return { provider: "anthropic", key: process.env.ANTHROPIC_API_KEY, model: m || PROVIDERS.anthropic.model, source: "ANTHROPIC_* (environment)" };
106
+ if (process.env.OPENAI_API_KEY) return { provider: "openai", key: process.env.OPENAI_API_KEY, model: m || PROVIDERS.openai.model, source: "OPENAI_API_KEY (environment)" };
107
+ if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return { provider: "gemini", key: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY, model: m || PROVIDERS.gemini.model, source: "GEMINI/GOOGLE_API_KEY (environment)" };
108
+ if (antProfileExists()) return { provider: "anthropic", model: m || PROVIDERS.anthropic.model, source: "Anthropic CLI profile (ant auth login)" };
109
+ return null;
92
110
  }
93
111
 
94
112
  // ---- prompt (with masked secret input) ------------------------------------
@@ -179,41 +197,101 @@ function openWork(repos) {
179
197
  return items;
180
198
  }
181
199
 
182
- // ---- claude ---------------------------------------------------------------
200
+ // ---- model calls (one per provider, same in/out) --------------------------
201
+ async function callAnthropic(r, system, prompt) {
202
+ const client = new Anthropic(r.key ? { apiKey: r.key } : {});
203
+ const base = { model: r.model, max_tokens: MAX_TOKENS, system, messages: [{ role: "user", content: prompt }] };
204
+ let res;
205
+ try { res = await client.messages.create({ ...base, output_config: { effort: "low" } }); }
206
+ catch (e) {
207
+ // effort/output_config isn't accepted on every model (e.g. Haiku) — retry plain
208
+ if (/effort|output_config|thinking|budget|400/i.test(e?.message || "")) res = await client.messages.create(base);
209
+ else throw e;
210
+ }
211
+ if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
212
+ return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
213
+ }
214
+ async function callOpenAI(r, system, prompt) {
215
+ const messages = [{ role: "system", content: system }, { role: "user", content: prompt }];
216
+ // Newer models want max_completion_tokens instead of max_tokens; try both.
217
+ for (const tokKey of ["max_tokens", "max_completion_tokens"]) {
218
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
219
+ method: "POST",
220
+ headers: { Authorization: `Bearer ${r.key}`, "Content-Type": "application/json" },
221
+ body: JSON.stringify({ model: r.model, [tokKey]: MAX_TOKENS, messages }),
222
+ });
223
+ const text = await res.text();
224
+ if (res.ok) return (JSON.parse(text).choices?.[0]?.message?.content || "").trim();
225
+ if (res.status === 400 && tokKey === "max_tokens" && /max_tokens|max_completion_tokens/i.test(text)) continue;
226
+ throw new Error(`OpenAI ${res.status}: ${text.slice(0, 200)}`);
227
+ }
228
+ return "";
229
+ }
230
+ async function callGemini(r, system, prompt) {
231
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(r.model)}:generateContent?key=${encodeURIComponent(r.key)}`;
232
+ const res = await fetch(url, {
233
+ method: "POST", headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify({
235
+ systemInstruction: { parts: [{ text: system }] },
236
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
237
+ generationConfig: { maxOutputTokens: MAX_TOKENS },
238
+ }),
239
+ });
240
+ const text = await res.text();
241
+ if (!res.ok) throw new Error(`Gemini ${res.status}: ${text.slice(0, 200)}`);
242
+ const parts = JSON.parse(text).candidates?.[0]?.content?.parts || [];
243
+ return parts.map((p) => p.text || "").join("").trim();
244
+ }
245
+ async function callOllama(r, system, prompt) {
246
+ const res = await fetch(`${r.baseUrl}/api/chat`, {
247
+ method: "POST", headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
249
+ });
250
+ const text = await res.text();
251
+ if (!res.ok) throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
252
+ return (JSON.parse(text).message?.content || "").trim();
253
+ }
254
+
183
255
  async function write(system, prompt) {
184
- const conn = getClient();
185
- if (!conn) { console.log(AUTH_HELP); return null; }
256
+ const r = resolveProvider();
257
+ if (!r) { console.log(AUTH_HELP); return null; }
186
258
  const stop = spinner("thinking…");
187
259
  try {
188
- const res = await conn.client.messages.create({
189
- model: MODEL, max_tokens: 1600,
190
- output_config: { effort: "low" }, // a summary doesn't need deep reasoning — keeps it fast + cheap
191
- system, messages: [{ role: "user", content: prompt }],
192
- });
193
- if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
194
- return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
260
+ if (r.provider === "anthropic") return await callAnthropic(r, system, prompt);
261
+ if (r.provider === "openai") return await callOpenAI(r, system, prompt);
262
+ if (r.provider === "gemini") return await callGemini(r, system, prompt);
263
+ if (r.provider === "ollama") return await callOllama(r, system, prompt);
264
+ return null;
195
265
  } catch (err) {
196
- if (err instanceof Anthropic.AuthenticationError || /api key|ANTHROPIC_API_KEY|authentication|credential|401/i.test(err?.message || "")) {
197
- console.log(c.y("Your Claude key was rejected. ") + "Re-connect with: " + c.b("symbiot login --force"));
266
+ if (/\b401\b|\b403\b|invalid|authentication|api key|unauthor/i.test(err?.message || "")) {
267
+ console.log(c.y(`Your ${PROVIDERS[r.provider].label} credentials were rejected. `) + "Reconnect with: " + c.b("symbiot login --force"));
198
268
  return null;
199
269
  }
200
270
  return `Couldn't reach the model: ${err?.message || err}`;
201
271
  } finally { stop(); }
202
272
  }
203
273
 
274
+ async function validate(provider, { key, baseUrl } = {}) {
275
+ try {
276
+ if (provider === "anthropic") { await new Anthropic({ apiKey: key }).models.list(); return true; }
277
+ if (provider === "openai") return (await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${key}` } })).ok;
278
+ if (provider === "gemini") return (await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`)).ok;
279
+ if (provider === "ollama") return (await fetch(`${baseUrl}/api/tags`)).ok;
280
+ } catch { return false; }
281
+ return false;
282
+ }
283
+
204
284
  const AUTH_HELP =
205
- c.y("Symbiot writes your updates with Claude, so it needs an Anthropic API key.\n") +
206
- " Fastest: " + c.b("symbiot login") + c.d(" paste a key once; saved to ~/.config/symbiot") + "\n" +
207
- " Or set: export ANTHROPIC_API_KEY=sk-ant-...\n" +
208
- " Get a key: https://console.anthropic.com/settings/keys\n" +
209
- c.d(" (If you use the Anthropic CLI, `ant auth login` works too.)");
285
+ c.y("Symbiot needs an AI to write your updates. Connect one:\n") +
286
+ " " + c.b("symbiot login") + c.d(" pick Claude, OpenAI, Gemini, or a local model (Ollama)") + "\n" +
287
+ c.d(" Or set a key in your environment: ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.");
210
288
 
211
289
  // ---- render ---------------------------------------------------------------
212
290
  function renderCommits(list) {
213
291
  return list.map((x) => `- [${x.repo}] ${x.subject}${x.files.length ? ` (${x.files.length} files)` : ""}`).join("\n");
214
292
  }
215
- function header(title, sub) {
216
- if (PLAIN) { console.log(`${title}\n${sub}\n`); return; }
293
+ function header(sub) {
294
+ if (PLAIN) { console.log(`Symbiot\n${sub}\n`); return; }
217
295
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} ${c.d("· " + sub)}\n`);
218
296
  }
219
297
 
@@ -221,21 +299,30 @@ function header(title, sub) {
221
299
  const SINCE_WEEK = Number(flag("since", "7"));
222
300
  const BASE = flag("dir", homedir());
223
301
 
224
- async function cmdWeek(days, label) {
225
- if (!resolveAuth()) { console.log(AUTH_HELP); return; }
302
+ // Build a write-up for a command; returns { text, sub, error? } without printing.
303
+ // Shared by the CLI (cmdRun) and the web UI (symbiot app).
304
+ async function produce(cmd) {
305
+ if (!resolveProvider()) return { error: "not-connected" };
306
+ if (cmd === "todo") {
307
+ const repos = findRepos(BASE, 60);
308
+ const open = openWork(repos);
309
+ if (!open.length) return { text: "Nothing outstanding found (no TODOs or uncommitted work).", sub: "0 open items · todo" };
310
+ const system =
311
+ `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
312
+ `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
313
+ `Be concise and concrete. No preamble.`;
314
+ const text = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
315
+ return { text: text || "(couldn't reach the model)", sub: `${open.length} open items · todo` };
316
+ }
317
+ const label = cmd === "standup" ? "standup" : "week";
318
+ const days = cmd === "standup" ? 2 : SINCE_WEEK;
226
319
  const who = me();
227
320
  const repos = findRepos(BASE, days);
228
- if (!repos.length) {
229
- console.log(c.y(`No git activity in the last ${days} days under ${BASE}.`));
230
- console.log(c.d("Try: symbiot week --dir ~/projects (point it at where your repos live)"));
231
- return;
232
- }
233
- // Your own commits (per-repo identity); --all includes everyone's.
321
+ if (!repos.length) return { text: `No git activity in the last ${days} days under ${BASE}.\nPoint it at your repos with --dir.`, sub: "no activity" };
234
322
  let cs = commits(repos, `${days} days ago`, !has("all"));
235
323
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
236
324
  const open = label === "week" ? openWork(repos) : [];
237
-
238
- if (!cs.length) { console.log(c.y("Found repos, but no commits in the window.")); return; }
325
+ if (!cs.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
239
326
 
240
327
  const system =
241
328
  `You write a short, first-person work update from a person's git commits. ` +
@@ -243,100 +330,288 @@ async function cmdWeek(days, label) {
243
330
  `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
244
331
  `${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
245
332
  `No preamble, no sign-off, no invented work — only what the commits and open items show.`;
246
-
247
333
  const prompt =
248
334
  `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
249
335
  `Commits:\n${renderCommits(cs)}\n\n` +
250
336
  (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
251
337
  `Write the ${label === "standup" ? "standup" : "update"}.`;
252
338
 
253
- header("Symbiot", `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`);
254
- const out = await write(system, prompt);
255
- if (out) console.log(out + "\n");
339
+ const text = await write(system, prompt);
340
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}` };
256
341
  }
257
342
 
258
- async function cmdTodo() {
259
- if (!resolveAuth()) { console.log(AUTH_HELP); return; }
260
- const repos = findRepos(BASE, 60);
261
- const open = openWork(repos);
262
- if (!open.length) { console.log(c.y("Nothing outstanding found (no TODOs or uncommitted work).")); return; }
263
- const system =
264
- `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
265
- `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
266
- `Be concise and concrete. No preamble.`;
267
- header("Symbiot", `${open.length} open items · todo`);
268
- const out = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
269
- if (out) console.log(out + "\n");
343
+ async function cmdRun(cmd) {
344
+ if (!resolveProvider()) { console.log(AUTH_HELP); return; }
345
+ const r = await produce(cmd);
346
+ header(r.sub || cmd);
347
+ if (r.text) console.log(r.text + "\n");
348
+ }
349
+
350
+ // Save a provider connection (used by the web Settings panel); mirrors cmdLogin.
351
+ async function connectProvider(b) {
352
+ const provider = b && b.provider;
353
+ if (!PROVIDERS[provider]) return { ok: false, message: "Unknown provider." };
354
+ const cfg = loadConfig();
355
+ if (provider === "ollama") {
356
+ const baseUrl = (b.baseUrl || "http://localhost:11434").trim();
357
+ const model = (b.model || "").trim() || PROVIDERS.ollama.model;
358
+ if (!(await validate("ollama", { baseUrl }))) return { ok: false, message: `Couldn't reach Ollama at ${baseUrl}. Is it running?` };
359
+ cfg.provider = "ollama"; cfg.ollama = { baseUrl, model }; delete cfg.apiKey;
360
+ return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS.ollama.label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
361
+ }
362
+ const key = (b.key || "").trim();
363
+ if (!key) return { ok: false, message: "No key entered." };
364
+ const model = (b.model || "").trim() || PROVIDERS[provider].model;
365
+ if (!(await validate(provider, { key }))) return { ok: false, message: `That key didn't work for ${PROVIDERS[provider].label}.` };
366
+ cfg.provider = provider; cfg[provider] = { apiKey: key, model }; delete cfg.apiKey;
367
+ return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
368
+ }
369
+
370
+ function saveAndReport(cfg, what) {
371
+ if (saveConfig(cfg)) {
372
+ console.log(c.g("✓ ") + `Connected: ${what}. Try: ` + c.b("symbiot week"));
373
+ console.log(c.d(` Saved in ${CONFIG_PATH} (readable only by you).`));
374
+ } else console.log(c.y("Couldn't write the config file at " + CONFIG_PATH));
270
375
  }
271
376
 
272
377
  async function cmdLogin() {
273
- const existing = resolveAuth();
274
- const provided = flag("key", null);
275
- if (existing && !provided && !has("force")) {
276
- console.log(c.g("✓ ") + `Already connected — Symbiot is using ${existing.source}.`);
277
- console.log(c.d(" Replace it with `symbiot login --force`, or just run `symbiot week`."));
378
+ const flagProvider = flag("provider", null);
379
+ const existing = resolveProvider();
380
+ if (existing && !flagProvider && !flag("key", null) && !has("force")) {
381
+ console.log(c.g("✓ ") + `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`);
382
+ console.log(c.d(" Switch or replace it with `symbiot login --force`."));
278
383
  return;
279
384
  }
280
- console.log("\n" + c.b("Connect Symbiot to Claude") + "\n");
281
- console.log("Symbiot writes your updates with Claude (Anthropic). It needs an API key,");
282
- console.log("stored locally on this machine and used only to write your updates.\n");
283
- console.log(c.d(" Get a key at: https://console.anthropic.com/settings/keys") + "\n");
284
385
 
285
- let key = provided;
286
- if (!key) key = await ask("Paste your Anthropic API key (sk-ant-…): ", { secret: true });
386
+ let provider = flagProvider;
387
+ if (!provider) {
388
+ console.log("\n" + c.b("Connect Symbiot") + "\n");
389
+ console.log("Which AI should Symbiot write your updates with?\n");
390
+ console.log(" 1) " + PROVIDERS.anthropic.label + c.d(" — needs an Anthropic API key"));
391
+ console.log(" 2) " + PROVIDERS.openai.label + c.d(" — needs an OpenAI API key"));
392
+ console.log(" 3) " + PROVIDERS.gemini.label + c.d(" — needs a Google AI API key"));
393
+ console.log(" 4) " + PROVIDERS.ollama.label + c.d(" — runs on your machine, no key"));
394
+ const pick = (await ask("\nChoose 1-4 [1]: ")) || "1";
395
+ provider = { 1: "anthropic", 2: "openai", 3: "gemini", 4: "ollama" }[pick] || (PROVIDERS[pick] ? pick : "anthropic");
396
+ }
397
+ if (!PROVIDERS[provider]) { console.log(c.y("Unknown provider: " + provider)); return; }
398
+ const meta = PROVIDERS[provider];
399
+ const cfg = loadConfig();
400
+
401
+ if (provider === "ollama") {
402
+ const baseUrl = (flag("base-url", null) || (await ask("Ollama URL [http://localhost:11434]: ")) || "").trim() || "http://localhost:11434";
403
+ const model = (flag("model", null) || (await ask(`Model name [${meta.model}]: `)) || "").trim() || meta.model;
404
+ const stop = spinner("checking Ollama…");
405
+ const ok = await validate("ollama", { baseUrl });
406
+ stop();
407
+ if (!ok) { console.log(c.y(`Couldn't reach Ollama at ${baseUrl}. `) + c.d("Is it running? (try: ollama serve)")); process.exitCode = 1; return; }
408
+ cfg.provider = "ollama"; cfg.ollama = { baseUrl, model }; delete cfg.apiKey;
409
+ saveAndReport(cfg, `${meta.label} · ${model}`);
410
+ return;
411
+ }
412
+
413
+ console.log("\n" + c.b(`Connect ${meta.label}`) + "\n" + c.d(` Get a key at: ${meta.keyUrl}`) + "\n");
414
+ let key = flag("key", null);
415
+ if (!key) key = await ask(`Paste your ${meta.keyName}: `, { secret: true });
287
416
  key = (key || "").trim();
288
417
  if (!key) { console.log(c.y("No key entered — nothing saved.")); return; }
289
- if (!/^sk-ant-/.test(key)) console.log(c.d("(that doesn't look like an sk-ant- key, but I'll try it)"));
418
+ const model = (flag("model", null) || "").trim() || meta.model;
290
419
 
291
420
  const stop = spinner("checking the key…");
292
- let ok = false, why = "";
293
- try { await new Anthropic({ apiKey: key }).models.list(); ok = true; }
294
- catch (e) {
295
- if (e instanceof Anthropic.AuthenticationError || /401|invalid|authentication/i.test(e?.message || ""))
296
- why = "Anthropic rejected it — the key looks invalid.";
297
- else why = `couldn't reach Anthropic — ${e?.message || e}`;
298
- }
299
- finally { stop(); }
300
-
421
+ const ok = await validate(provider, { key });
422
+ stop();
301
423
  if (!ok) {
302
- console.log(c.y("That key didn't work: ") + why);
303
- console.log(c.d("Double-check it and run `symbiot login` again."));
424
+ console.log(c.y(`That key didn't work for ${meta.label}. `) + c.d("Double-check it and run `symbiot login` again."));
304
425
  process.exitCode = 1;
305
426
  return;
306
427
  }
307
- if (saveConfig({ ...loadConfig(), apiKey: key })) {
308
- console.log(c.g("✓ ") + "Connected. You're set — try: " + c.b("symbiot week"));
309
- console.log(c.d(` Key saved in ${CONFIG_PATH} (readable only by you).`));
310
- } else {
311
- console.log(c.y("Couldn't write the config file. Set it in your shell instead:"));
312
- console.log(" export ANTHROPIC_API_KEY=" + key);
313
- }
428
+ cfg.provider = provider; cfg[provider] = { apiKey: key, model }; delete cfg.apiKey;
429
+ saveAndReport(cfg, `${meta.label} · ${model}`);
314
430
  }
315
431
 
316
432
  function cmdLogout() {
317
433
  const cfg = loadConfig();
318
- if (!cfg.apiKey) { console.log("No saved key to remove."); return; }
319
- delete cfg.apiKey;
434
+ const had = cfg.provider || cfg.apiKey || Object.keys(PROVIDERS).some((p) => cfg[p]);
435
+ delete cfg.provider; delete cfg.apiKey;
436
+ for (const p of Object.keys(PROVIDERS)) delete cfg[p];
320
437
  saveConfig(cfg);
321
- console.log(c.g("✓ ") + `Removed the saved key from ${CONFIG_PATH}.`);
322
- if (process.env.ANTHROPIC_API_KEY) console.log(c.d("Note: ANTHROPIC_API_KEY is still set in your environment."));
438
+ console.log(had ? c.g("✓ ") + `Cleared saved credentials from ${CONFIG_PATH}.` : "No saved credentials to remove.");
439
+ const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"].filter((e) => process.env[e]);
440
+ if (envs.length) console.log(c.d(`Note: still set in your environment: ${envs.join(", ")}.`));
323
441
  }
324
442
 
325
443
  function cmdWhoami() {
326
- const a = resolveAuth();
327
- if (a) console.log(c.g("✓ ") + `Symbiot is connected — using ${a.source}. Model: ${MODEL}`);
444
+ const r = resolveProvider();
445
+ if (r) console.log(c.g("✓ ") + `Connected: ${PROVIDERS[r.provider].label} · model ${r.model} · via ${r.source}.`);
328
446
  else { console.log(c.y("Not connected yet.\n")); console.log(AUTH_HELP); }
329
447
  }
330
448
 
449
+ // ---- `symbiot app` : the same UI in a chrome-less browser window ----------
450
+ // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
451
+ // template literal). Talks to the local API with the per-launch token.
452
+ const EMBEDDED_UI = `<!doctype html><html><head><meta charset="utf8">
453
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>Symbiot</title>
454
+ <style>
455
+ :root{--ink:#0E1A1F;--ink2:#15262C;--ink3:#1D333A;--line:#24404A;--bone:#F4F1EA;--text:#B7C9C4;--faint:#7E9690;--green:#3DDC97;--amber:#F2A541;--green-dim:#16322D;--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
456
+ *{box-sizing:border-box}html,body{margin:0;height:100%}
457
+ body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column}
458
+ header{padding:16px 18px 10px;display:flex;align-items:center;gap:10px}
459
+ .dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 12px var(--green)}
460
+ .brand{font-weight:700;color:var(--bone);font-size:16px}
461
+ .status{margin-left:auto;font-size:12px;color:var(--faint);text-align:right;max-width:52%}
462
+ .tabs{display:flex;gap:6px;padding:0 14px;border-bottom:1px solid var(--line)}
463
+ .tab{padding:9px 14px;border:0;background:none;color:var(--faint);font:inherit;font-weight:600;cursor:pointer;border-bottom:2px solid transparent}
464
+ .tab.active{color:var(--bone);border-bottom-color:var(--green)}
465
+ main{flex:1;overflow:auto;padding:16px 18px}
466
+ .row{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
467
+ button.act{background:var(--green);color:var(--ink);border:0;border-radius:10px;padding:10px 16px;font:inherit;font-weight:700;cursor:pointer}
468
+ button.act:hover{filter:brightness(1.08)}
469
+ button.ghost{background:var(--ink3);color:var(--bone);border:1px solid var(--line);border-radius:10px;padding:9px 14px;font:inherit;font-weight:600;cursor:pointer}
470
+ button.ghost:hover{border-color:var(--faint)}
471
+ .out{white-space:pre-wrap;background:var(--ink2);border:1px solid var(--line);border-radius:12px;padding:16px;min-height:180px;color:var(--bone);line-height:1.6}
472
+ .muted{color:var(--faint)}
473
+ label{display:block;font-size:12px;color:var(--faint);margin:14px 0 5px}
474
+ select,input{width:100%;background:var(--ink);border:1px solid var(--line);border-radius:9px;padding:10px 12px;color:var(--bone);font:inherit}
475
+ select:focus,input:focus{outline:none;border-color:var(--green)}
476
+ a{color:var(--green);cursor:pointer}.hidden{display:none}
477
+ .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
478
+ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
479
+ </style></head><body>
480
+ <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
481
+ <div class="tabs">
482
+ <button class="tab active" data-tab="week">Week</button>
483
+ <button class="tab" data-tab="standup">Standup</button>
484
+ <button class="tab" data-tab="todo">Todo</button>
485
+ <button class="tab" data-tab="settings">Settings</button>
486
+ </div>
487
+ <main>
488
+ <section id="panel-run">
489
+ <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
490
+ <button class="ghost hidden" id="copy">Copy</button>
491
+ <span class="muted">Reads your local git and writes it up.</span></div>
492
+ <div class="out muted" id="out">Nothing yet - hit the button.</div>
493
+ </section>
494
+ <section id="panel-settings" class="hidden">
495
+ <label>Which AI should Symbiot write with?</label>
496
+ <select id="provider">
497
+ <option value="anthropic">Claude (Anthropic)</option>
498
+ <option value="openai">OpenAI (GPT)</option>
499
+ <option value="gemini">Gemini (Google)</option>
500
+ <option value="ollama">Local model (Ollama)</option>
501
+ </select>
502
+ <div id="keyWrap"><label>API key</label><input id="key" type="password" placeholder="paste your key">
503
+ <div class="note"><a id="getkey">Where do I get a key?</a></div></div>
504
+ <div id="baseWrap" class="hidden"><label>Ollama URL</label><input id="baseUrl" type="text" value="http://localhost:11434"></div>
505
+ <label>Model <span class="muted" id="modelHint"></span></label>
506
+ <input id="model" type="text" placeholder="(blank = default)">
507
+ <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
508
+ <span class="note" id="saveMsg"></span></div>
509
+ </section>
510
+ </main>
511
+ <footer><button class="ghost" id="quit" style="margin-left:auto">Quit</button></footer>
512
+ <script>
513
+ var T=new URLSearchParams(window.location.search).get('t')||'';
514
+ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-symbiot-token':T,'content-type':'application/json'},body:body?JSON.stringify(body):undefined}).then(function(r){return r.json();});}
515
+ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
516
+ var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
517
+ function $(id){return document.getElementById(id);}
518
+ var current='week';
519
+ function tabs(){return document.querySelectorAll('.tab');}
520
+ function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
521
+ var s=tab==='settings';$('panel-run').classList.toggle('hidden',s);$('panel-settings').classList.toggle('hidden',!s);
522
+ if(!s){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}}
523
+ tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
524
+ function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
525
+ $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
526
+ api('/api/run',{cmd:current}).then(function(r){if(r.error==='not-connected'){$('out').textContent='Not connected yet - open Settings and pick an AI.';return;}
527
+ $('out').textContent=r.text||'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');});});
528
+ $('copy').addEventListener('click',function(){navigator.clipboard.writeText($('out').textContent);$('copy').textContent='Copied';setTimeout(function(){$('copy').textContent='Copy';},1400);});
529
+ function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
530
+ $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
531
+ $('provider').addEventListener('change',syncP);
532
+ $('getkey').addEventListener('click',function(){window.open(KEYURL[$('provider').value],'_blank');});
533
+ $('save').addEventListener('click',function(){var p=$('provider').value;$('saveMsg').textContent='checking...';$('saveMsg').className='note muted';
534
+ var cfg={provider:p,model:$('model').value.trim()};if(p==='ollama')cfg.baseUrl=$('baseUrl').value.trim();else cfg.key=$('key').value.trim();
535
+ api('/api/connect',cfg).then(function(r){$('saveMsg').textContent=r.message||(r.ok?'Connected.':'Could not connect.');$('saveMsg').className='note '+(r.ok?'ok':'err');if(r.ok){$('key').value='';refresh();}});});
536
+ $('quit').addEventListener('click',function(){api('/api/quit');document.body.innerHTML='<div style=\\'padding:40px;color:#7E9690;font-family:sans-serif\\'>Symbiot stopped. You can close this window.</div>';});
537
+ syncP();refresh();
538
+ </script></body></html>`;
539
+
540
+ function readBody(req) {
541
+ return new Promise((resolve) => {
542
+ let d = ""; req.on("data", (ch) => (d += ch));
543
+ req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
544
+ });
545
+ }
546
+ function onPath(cands) { for (const cc of cands) { if (sh(`command -v ${cc} 2>/dev/null`).trim()) return cc; } return null; }
547
+ // Find a Chromium-family browser for the chrome-less --app window, per OS.
548
+ function chromeBinary() {
549
+ const p = process.platform;
550
+ const exists = (f) => { try { return existsSync(f) ? f : null; } catch { return null; } };
551
+ if (p === "darwin") {
552
+ return ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
553
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
554
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
555
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"].map(exists).find(Boolean) || null;
556
+ }
557
+ if (p === "win32") {
558
+ const bases = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
559
+ const rels = ["Google\\Chrome\\Application\\chrome.exe", "Chromium\\Application\\chrome.exe",
560
+ "Microsoft\\Edge\\Application\\msedge.exe", "BraveSoftware\\Brave-Browser\\Application\\brave.exe"];
561
+ for (const base of bases) for (const r of rels) { const f = exists(join(base, r)); if (f) return f; }
562
+ const w = sh("where chrome 2>NUL").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
563
+ return w || null;
564
+ }
565
+ return onPath(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]);
566
+ }
567
+ function openApp(url) {
568
+ try {
569
+ const chrome = chromeBinary();
570
+ if (chrome) { spawn(chrome, [`--app=${url}`, "--new-window"], { detached: true, stdio: "ignore" }).unref(); return "app window"; }
571
+ // fall back to the OS default browser (a normal tab) — still fully functional
572
+ if (process.platform === "win32") { spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); return "browser tab"; }
573
+ spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
574
+ return "browser tab";
575
+ } catch { return null; }
576
+ }
577
+ async function cmdApp() {
578
+ SERVING = true;
579
+ const TOKEN = randomBytes(16).toString("hex");
580
+ const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
581
+ const server = createServer(async (req, res) => {
582
+ const u = new URL(req.url, "http://127.0.0.1");
583
+ if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
584
+ if (u.pathname.startsWith("/api/")) {
585
+ const tok = req.headers["x-symbiot-token"] || u.searchParams.get("t");
586
+ if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
587
+ }
588
+ try {
589
+ if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
590
+ if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
591
+ if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
592
+ if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
593
+ } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
594
+ res.writeHead(404); res.end("not found");
595
+ });
596
+ server.listen(0, "127.0.0.1", () => {
597
+ const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
598
+ const how = openApp(url);
599
+ console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
600
+ console.log(how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
601
+ console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
602
+ });
603
+ }
604
+
331
605
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
332
606
 
333
607
  ${c.b("Usage")}
334
608
  symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
335
609
  symbiot standup yesterday + today, for standup
336
610
  symbiot todo what's still on your plate
337
- symbiot login connect it to Claude (once)
611
+ symbiot app open the visual app in your browser
612
+ symbiot login connect it to an AI (once)
338
613
  symbiot whoami show how it's connected
339
- symbiot logout forget the saved key
614
+ symbiot logout forget saved credentials
340
615
  symbiot help
341
616
 
342
617
  ${c.b("Options")}
@@ -345,13 +620,15 @@ ${c.b("Options")}
345
620
  --all everyone's commits, not just yours
346
621
  --plain no colour/spinner (good for piping)
347
622
 
348
- ${c.b("Setup")} it writes with Claude, so it needs one Anthropic key:
349
- symbiot login ${c.d("paste a key once (recommended)")}
350
- export ANTHROPIC_API_KEY=sk-ant-... ${c.d("or set it yourself")}
351
- ${c.d("Change the model with SYMBIOT_MODEL (default " + MODEL + "; e.g. claude-haiku-4-5 is cheaper).")}
623
+ ${c.b("Setup")} pick any AI to write with:
624
+ symbiot login ${c.d("choose Claude / OpenAI / Gemini / Ollama")}
625
+ symbiot login --provider openai --key sk-... ${c.d("(non-interactive)")}
626
+ ${c.d("Env keys also work: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY.")}
627
+ ${c.d("Override the model per run with SYMBIOT_MODEL.")}
352
628
 
353
629
  ${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
354
- ${c.d("commit summaries sent to the model to write your update.")}`;
630
+ ${c.d("commit summaries sent to the AI to write your update (nothing leaves at all")}
631
+ ${c.d("with a local Ollama model).")}`;
355
632
 
356
633
  // ---- main -----------------------------------------------------------------
357
634
  (async () => {
@@ -359,8 +636,9 @@ ${c.d("commit summaries sent to the model to write your update.")}`;
359
636
  if (cmd === "login" || cmd === "auth") return cmdLogin();
360
637
  if (cmd === "logout") return cmdLogout();
361
638
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
362
- if (cmd === "week") return cmdWeek(SINCE_WEEK, "week");
363
- if (cmd === "standup") return cmdWeek(2, "standup");
364
- if (cmd === "todo") return cmdTodo();
639
+ if (cmd === "app" || cmd === "ui") return cmdApp();
640
+ if (cmd === "week") return cmdRun("week");
641
+ if (cmd === "standup") return cmdRun("standup");
642
+ if (cmd === "todo") return cmdRun("todo");
365
643
  console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
366
644
  })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.2.0",
4
- "description": "Your week, written from your real work. Reads your local git — no accounts, no OAuth, just an AI key you bring yourself.",
3
+ "version": "0.4.1",
4
+ "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "symbiot": "index.mjs"
@@ -21,6 +21,10 @@
21
21
  "changelog",
22
22
  "productivity",
23
23
  "ai",
24
+ "claude",
25
+ "openai",
26
+ "gemini",
27
+ "ollama",
24
28
  "developer-tools",
25
29
  "cli"
26
30
  ],