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.
- package/README.md +44 -17
- package/index.mjs +391 -113
- 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.
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
45
|
+
Pick the AI you want it to write with:
|
|
28
46
|
|
|
29
47
|
```bash
|
|
30
|
-
symbiot login #
|
|
48
|
+
symbiot login # choose Claude, OpenAI, Gemini, or a local model (Ollama)
|
|
31
49
|
```
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
- `
|
|
67
|
-
|
|
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
|
|
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
|
|
6
|
-
//
|
|
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
|
|
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
|
-
// ----
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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;
|
|
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
|
-
// ----
|
|
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
|
|
185
|
-
if (!
|
|
256
|
+
const r = resolveProvider();
|
|
257
|
+
if (!r) { console.log(AUTH_HELP); return null; }
|
|
186
258
|
const stop = spinner("thinking…");
|
|
187
259
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 (
|
|
197
|
-
console.log(c.y(
|
|
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
|
|
206
|
-
"
|
|
207
|
-
" Or set:
|
|
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(
|
|
216
|
-
if (PLAIN) { console.log(
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
259
|
-
if (!
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
274
|
-
const
|
|
275
|
-
if (existing && !
|
|
276
|
-
console.log(c.g("✓ ") + `Already connected —
|
|
277
|
-
console.log(c.d("
|
|
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
|
|
286
|
-
if (!
|
|
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
|
-
|
|
418
|
+
const model = (flag("model", null) || "").trim() || meta.model;
|
|
290
419
|
|
|
291
420
|
const stop = spinner("checking the key…");
|
|
292
|
-
|
|
293
|
-
|
|
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(
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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("✓ ") + `
|
|
322
|
-
|
|
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
|
|
327
|
-
if (
|
|
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 & 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
|
|
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
|
|
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")}
|
|
349
|
-
symbiot login
|
|
350
|
-
|
|
351
|
-
${c.d("
|
|
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
|
|
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 === "
|
|
363
|
-
if (cmd === "
|
|
364
|
-
if (cmd === "
|
|
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.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git
|
|
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
|
],
|