symbiot 0.1.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +43 -9
  2. package/index.mjs +443 -52
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -4,28 +4,59 @@ 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 app open the visual app in your browser
15
+ symbiot login connect it to an AI (once)
16
+ symbiot whoami show how it's connected
14
17
  symbiot help
15
18
  ```
16
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
+
17
32
  ## Install
18
33
 
19
34
  ```bash
20
35
  npm install -g symbiot # or run without installing: npx symbiot week
21
36
  ```
22
37
 
23
- Then give it an AI key (this is the only credential it needs):
38
+ ## Connect it (once)
39
+
40
+ Pick the AI you want it to write with:
24
41
 
25
42
  ```bash
26
- export ANTHROPIC_API_KEY=sk-ant-... # or: ant auth login
43
+ symbiot login # choose Claude, OpenAI, Gemini, or a local model (Ollama)
27
44
  ```
28
45
 
46
+ You'll get a short menu; paste that provider's API key (or, for Ollama, just point
47
+ it at your local server) and it's saved to `~/.config/symbiot/config.json`.
48
+
49
+ | Provider | Get a key | Default model |
50
+ |----------|-----------|---------------|
51
+ | Claude (Anthropic) | <https://console.anthropic.com/settings/keys> | `claude-opus-5-5` |
52
+ | OpenAI (GPT) | <https://platform.openai.com/api-keys> | `gpt-4o-mini` |
53
+ | Gemini (Google) | <https://aistudio.google.com/apikey> | `gemini-1.5-flash` |
54
+ | Local (Ollama) | runs on your machine, no key | `llama3.1` |
55
+
56
+ Non-interactive: `symbiot login --provider openai --key sk-... [--model gpt-4o]`.
57
+ Environment keys are auto-detected too (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
58
+ `GEMINI_API_KEY`), and `symbiot whoami` shows which one is active.
59
+
29
60
  ## Use
30
61
 
31
62
  ```bash
@@ -46,17 +77,20 @@ Point it somewhere specific with `--dir ~/work`.
46
77
 
47
78
  - **Reads:** your local git — commit messages and changed-file names, plus
48
79
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
49
- - **Sends to the model:** only those commit/TODO summaries, so it can write the
50
- update. Not your code, not file contents.
80
+ - **Sends to the AI:** only those commit/TODO summaries, so it can write the
81
+ update. Not your code, not file contents. With a **local Ollama model,
82
+ nothing leaves your machine at all.**
51
83
  - **Never:** no keylogging, no screen capture, no browsing history, no accounts.
52
84
 
53
85
  ## Config
54
86
 
55
- - `SYMBIOT_MODEL` — the model to use (default `claude-opus-5`).
56
- - `ANTHROPIC_API_KEY` — your key, or sign in once with `ant auth login`.
87
+ - `SYMBIOT_MODEL` — override the model for any provider (e.g. `gpt-4o`,
88
+ `claude-haiku-4-5`, `gemini-1.5-pro`).
89
+ - `symbiot whoami` shows the active provider, model, and where the credential
90
+ came from. `symbiot logout` forgets saved credentials.
57
91
 
58
92
  ---
59
93
 
60
94
  Part of Symbiot — an assistant that learns how you work and gives it back to you.
61
95
  This CLI is the personal, install-it-yourself front door: it needs nothing but your
62
- own machine and one key.
96
+ own machine and an AI of your choice.
package/index.mjs CHANGED
@@ -2,12 +2,15 @@
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. The only thing it needs is an AI key,
6
- // which you bring yourself: export ANTHROPIC_API_KEY=sk-ant-... (or `ant auth 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
12
+ // symbiot login connect it to an AI (once)
13
+ // symbiot whoami show how it's connected
11
14
  // symbiot help
12
15
  //
13
16
  // Flags: --dir <path> where to look (default: your home folder)
@@ -15,12 +18,28 @@
15
18
  // --plain no colour, no spinner (for piping)
16
19
 
17
20
  import Anthropic from "@anthropic-ai/sdk";
18
- import { execSync } from "node:child_process";
21
+ import { execSync, spawn } from "node:child_process";
19
22
  import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
25
+ import { createInterface } from "node:readline";
26
+ import { createServer } from "node:http";
27
+ import { randomBytes } from "node:crypto";
20
28
 
21
- const MODEL = process.env.SYMBIOT_MODEL || "claude-opus-5";
22
29
  const MAX_REPOS = 14;
23
30
  const MAX_COMMITS = 140;
31
+ const MAX_TOKENS = 1600;
32
+ const CONFIG_DIR = join(homedir(), ".config", "symbiot");
33
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
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
+ };
24
43
 
25
44
  // ---- tiny arg parse --------------------------------------------------------
26
45
  const argv = process.argv.slice(2);
@@ -31,6 +50,7 @@ const flag = (name, def) => {
31
50
  };
32
51
  const has = (name) => argv.includes(`--${name}`);
33
52
  const PLAIN = has("plain") || !process.stdout.isTTY;
53
+ let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
34
54
 
35
55
  // ---- colour + spinner ------------------------------------------------------
36
56
  const c = PLAIN
@@ -42,6 +62,7 @@ const c = PLAIN
42
62
  y: (s) => `\x1b[38;5;179m${s}\x1b[0m`, // amber
43
63
  };
44
64
  function spinner(label) {
65
+ if (SERVING) return () => {};
45
66
  if (PLAIN) { process.stderr.write(label + "\n"); return () => {}; }
46
67
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
47
68
  let i = 0;
@@ -49,6 +70,64 @@ function spinner(label) {
49
70
  return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
50
71
  }
51
72
 
73
+ // ---- config + provider resolution -----------------------------------------
74
+ function loadConfig() {
75
+ try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
76
+ }
77
+ function saveConfig(cfg) {
78
+ try {
79
+ mkdirSync(CONFIG_DIR, { recursive: true });
80
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
81
+ try { chmodSync(CONFIG_PATH, 0o600); } catch {}
82
+ return true;
83
+ } catch { return false; }
84
+ }
85
+ function antProfileExists() {
86
+ try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
87
+ }
88
+ function envKey(provider) {
89
+ for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
90
+ return null;
91
+ }
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;
110
+ }
111
+
112
+ // ---- prompt (with masked secret input) ------------------------------------
113
+ function ask(question, { secret = false } = {}) {
114
+ return new Promise((resolve) => {
115
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
116
+ if (secret && !PLAIN) {
117
+ rl._writeToOutput = (str) => {
118
+ if (str.includes(question)) rl.output.write(question);
119
+ else if (str.includes("\n")) rl.output.write("\n");
120
+ else rl.output.write("*");
121
+ };
122
+ }
123
+ rl.question(question, (ans) => {
124
+ rl.close();
125
+ if (secret) process.stdout.write("\n");
126
+ resolve((ans || "").trim());
127
+ });
128
+ });
129
+ }
130
+
52
131
  // ---- git ------------------------------------------------------------------
53
132
  function sh(cmd) {
54
133
  try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }); }
@@ -118,38 +197,101 @@ function openWork(repos) {
118
197
  return items;
119
198
  }
120
199
 
121
- // ---- 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
+
122
255
  async function write(system, prompt) {
123
- let client;
124
- try { client = new Anthropic(); } catch { client = null; }
256
+ const r = resolveProvider();
257
+ if (!r) { console.log(AUTH_HELP); return null; }
125
258
  const stop = spinner("thinking…");
126
259
  try {
127
- const res = await client.messages.create({
128
- model: MODEL, max_tokens: 1600,
129
- system, messages: [{ role: "user", content: prompt }],
130
- });
131
- if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
132
- 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;
133
265
  } catch (err) {
134
- if (err instanceof Anthropic.AuthenticationError || /api key|ANTHROPIC_API_KEY|credential/i.test(err?.message || "")) {
135
- return AUTH_HELP;
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"));
268
+ return null;
136
269
  }
137
270
  return `Couldn't reach the model: ${err?.message || err}`;
138
271
  } finally { stop(); }
139
272
  }
140
273
 
141
- const AUTH_HELP = c.y("Symbiot needs an AI key to write your update.\n") +
142
- " Set one of these, then run again:\n" +
143
- " export ANTHROPIC_API_KEY=sk-ant-...\n" +
144
- " or: ant auth login\n" +
145
- c.d(" (This is the only key it needs — no Google, no integrations.)");
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
+
284
+ const AUTH_HELP =
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.");
146
288
 
147
289
  // ---- render ---------------------------------------------------------------
148
290
  function renderCommits(list) {
149
291
  return list.map((x) => `- [${x.repo}] ${x.subject}${x.files.length ? ` (${x.files.length} files)` : ""}`).join("\n");
150
292
  }
151
- function header(title, sub) {
152
- if (PLAIN) { console.log(`${title}\n${sub}\n`); return; }
293
+ function header(sub) {
294
+ if (PLAIN) { console.log(`Symbiot\n${sub}\n`); return; }
153
295
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} ${c.d("· " + sub)}\n`);
154
296
  }
155
297
 
@@ -157,20 +299,30 @@ function header(title, sub) {
157
299
  const SINCE_WEEK = Number(flag("since", "7"));
158
300
  const BASE = flag("dir", homedir());
159
301
 
160
- async function cmdWeek(days, label) {
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;
161
319
  const who = me();
162
320
  const repos = findRepos(BASE, days);
163
- if (!repos.length) {
164
- console.log(c.y(`No git activity in the last ${days} days under ${BASE}.`));
165
- console.log(c.d("Try: symbiot week --dir ~/projects (point it at where your repos live)"));
166
- return;
167
- }
168
- // 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" };
169
322
  let cs = commits(repos, `${days} days ago`, !has("all"));
170
323
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
171
324
  const open = label === "week" ? openWork(repos) : [];
172
-
173
- 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" };
174
326
 
175
327
  const system =
176
328
  `You write a short, first-person work update from a person's git commits. ` +
@@ -178,29 +330,255 @@ async function cmdWeek(days, label) {
178
330
  `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
179
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."} ` +
180
332
  `No preamble, no sign-off, no invented work — only what the commits and open items show.`;
181
-
182
333
  const prompt =
183
334
  `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
184
335
  `Commits:\n${renderCommits(cs)}\n\n` +
185
336
  (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
186
337
  `Write the ${label === "standup" ? "standup" : "update"}.`;
187
338
 
188
- header("Symbiot", `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`);
189
- console.log(await write(system, prompt));
190
- console.log("");
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}` };
191
341
  }
192
342
 
193
- async function cmdTodo() {
194
- const repos = findRepos(BASE, 60);
195
- const open = openWork(repos);
196
- if (!open.length) { console.log(c.y("Nothing outstanding found (no TODOs or uncommitted work).")); return; }
197
- const system =
198
- `You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
199
- `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
200
- `Be concise and concrete. No preamble.`;
201
- header("Symbiot", `${open.length} open items · todo`);
202
- console.log(await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`));
203
- console.log("");
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));
375
+ }
376
+
377
+ async function cmdLogin() {
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`."));
383
+ return;
384
+ }
385
+
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 });
416
+ key = (key || "").trim();
417
+ if (!key) { console.log(c.y("No key entered — nothing saved.")); return; }
418
+ const model = (flag("model", null) || "").trim() || meta.model;
419
+
420
+ const stop = spinner("checking the key…");
421
+ const ok = await validate(provider, { key });
422
+ stop();
423
+ if (!ok) {
424
+ console.log(c.y(`That key didn't work for ${meta.label}. `) + c.d("Double-check it and run `symbiot login` again."));
425
+ process.exitCode = 1;
426
+ return;
427
+ }
428
+ cfg.provider = provider; cfg[provider] = { apiKey: key, model }; delete cfg.apiKey;
429
+ saveAndReport(cfg, `${meta.label} · ${model}`);
430
+ }
431
+
432
+ function cmdLogout() {
433
+ const cfg = loadConfig();
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];
437
+ saveConfig(cfg);
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(", ")}.`));
441
+ }
442
+
443
+ function cmdWhoami() {
444
+ const r = resolveProvider();
445
+ if (r) console.log(c.g("✓ ") + `Connected: ${PROVIDERS[r.provider].label} · model ${r.model} · via ${r.source}.`);
446
+ else { console.log(c.y("Not connected yet.\n")); console.log(AUTH_HELP); }
447
+ }
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
+ function openApp(url) {
548
+ try {
549
+ const chrome = onPath(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]);
550
+ if (chrome) { spawn(chrome, [`--app=${url}`, "--new-window"], { detached: true, stdio: "ignore" }).unref(); return "app window"; }
551
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
552
+ spawn(opener, [url], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
553
+ return "browser tab";
554
+ } catch { return null; }
555
+ }
556
+ async function cmdApp() {
557
+ SERVING = true;
558
+ const TOKEN = randomBytes(16).toString("hex");
559
+ const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
560
+ const server = createServer(async (req, res) => {
561
+ const u = new URL(req.url, "http://127.0.0.1");
562
+ if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
563
+ if (u.pathname.startsWith("/api/")) {
564
+ const tok = req.headers["x-symbiot-token"] || u.searchParams.get("t");
565
+ if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
566
+ }
567
+ try {
568
+ if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
569
+ 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)); }
570
+ if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
571
+ if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
572
+ } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
573
+ res.writeHead(404); res.end("not found");
574
+ });
575
+ server.listen(0, "127.0.0.1", () => {
576
+ const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
577
+ const how = openApp(url);
578
+ console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
579
+ console.log(how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
580
+ console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
581
+ });
204
582
  }
205
583
 
206
584
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
@@ -209,24 +587,37 @@ ${c.b("Usage")}
209
587
  symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
210
588
  symbiot standup yesterday + today, for standup
211
589
  symbiot todo what's still on your plate
590
+ symbiot app open the visual app in your browser
591
+ symbiot login connect it to an AI (once)
592
+ symbiot whoami show how it's connected
593
+ symbiot logout forget saved credentials
212
594
  symbiot help
213
595
 
214
596
  ${c.b("Options")}
215
597
  --dir <path> where your repos are (default: ${homedir()})
216
598
  --since <days> window for 'week' (default 7)
599
+ --all everyone's commits, not just yours
217
600
  --plain no colour/spinner (good for piping)
218
601
 
219
- ${c.b("Setup")} one key, nothing else:
220
- export ANTHROPIC_API_KEY=sk-ant-... ${c.d("(or: ant auth login)")}
602
+ ${c.b("Setup")} pick any AI to write with:
603
+ symbiot login ${c.d("choose Claude / OpenAI / Gemini / Ollama")}
604
+ symbiot login --provider openai --key sk-... ${c.d("(non-interactive)")}
605
+ ${c.d("Env keys also work: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY.")}
606
+ ${c.d("Override the model per run with SYMBIOT_MODEL.")}
221
607
 
222
608
  ${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
223
- ${c.d("commit summaries sent to the model to write your update.")}`;
609
+ ${c.d("commit summaries sent to the AI to write your update (nothing leaves at all")}
610
+ ${c.d("with a local Ollama model).")}`;
224
611
 
225
612
  // ---- main -----------------------------------------------------------------
226
613
  (async () => {
227
614
  if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
228
- if (cmd === "week") return cmdWeek(SINCE_WEEK, "week");
229
- if (cmd === "standup") return cmdWeek(2, "standup");
230
- if (cmd === "todo") return cmdTodo();
615
+ if (cmd === "login" || cmd === "auth") return cmdLogin();
616
+ if (cmd === "logout") return cmdLogout();
617
+ if (cmd === "whoami" || cmd === "status") return cmdWhoami();
618
+ if (cmd === "app" || cmd === "ui") return cmdApp();
619
+ if (cmd === "week") return cmdRun("week");
620
+ if (cmd === "standup") return cmdRun("standup");
621
+ if (cmd === "todo") return cmdRun("todo");
231
622
  console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
232
623
  })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.1.1",
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.0",
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
  ],