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.
- package/README.md +43 -9
- package/index.mjs +443 -52
- 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.
|
|
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 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
|
-
|
|
38
|
+
## Connect it (once)
|
|
39
|
+
|
|
40
|
+
Pick the AI you want it to write with:
|
|
24
41
|
|
|
25
42
|
```bash
|
|
26
|
-
|
|
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
|
|
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
|
|
56
|
-
- `
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
// ----
|
|
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
|
-
|
|
124
|
-
|
|
256
|
+
const r = resolveProvider();
|
|
257
|
+
if (!r) { console.log(AUTH_HELP); return null; }
|
|
125
258
|
const stop = spinner("thinking…");
|
|
126
259
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 (
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
152
|
-
if (PLAIN) { console.log(
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 & 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")}
|
|
220
|
-
|
|
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
|
|
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 === "
|
|
229
|
-
if (cmd === "
|
|
230
|
-
if (cmd === "
|
|
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.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git
|
|
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
|
],
|