symbiot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -4
  2. package/index.mjs +153 -19
  3. package/package.json +33 -6
package/README.md CHANGED
@@ -11,6 +11,8 @@ bring yourself.
11
11
  symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
+ symbiot login connect it to Claude (once)
15
+ symbiot whoami show how it's connected
14
16
  symbiot help
15
17
  ```
16
18
 
@@ -20,12 +22,19 @@ symbiot help
20
22
  npm install -g symbiot # or run without installing: npx symbiot week
21
23
  ```
22
24
 
23
- Then give it an AI key (this is the only credential it needs):
25
+ ## Connect it (once)
26
+
27
+ Symbiot writes with **Claude**, so it needs an Anthropic API key. The easiest way:
24
28
 
25
29
  ```bash
26
- export ANTHROPIC_API_KEY=sk-ant-... # or: ant auth login
30
+ symbiot login # paste a key once — saved locally to ~/.config/symbiot
27
31
  ```
28
32
 
33
+ Get a key at <https://console.anthropic.com/settings/keys>. Prefer an env var? Set
34
+ `ANTHROPIC_API_KEY=sk-ant-...` instead and skip `login`. If you already use the
35
+ Anthropic CLI, `ant auth login` works too — Symbiot picks up any of these
36
+ automatically, and `symbiot whoami` shows which one it's using.
37
+
29
38
  ## Use
30
39
 
31
40
  ```bash
@@ -52,8 +61,11 @@ Point it somewhere specific with `--dir ~/work`.
52
61
 
53
62
  ## Config
54
63
 
55
- - `SYMBIOT_MODEL` — the model to use (default `claude-opus-5`).
56
- - `ANTHROPIC_API_KEY` — your key, or sign in once with `ant auth login`.
64
+ - `SYMBIOT_MODEL` — the model to use (default `claude-opus-5-5`; `claude-haiku-4-5`
65
+ is a cheaper option for this kind of summary).
66
+ - `ANTHROPIC_API_KEY` — your key. Or run `symbiot login` once, or use an
67
+ `ant auth login` profile. `symbiot whoami` shows which credential is active;
68
+ `symbiot logout` forgets a saved key.
57
69
 
58
70
  ---
59
71
 
package/index.mjs CHANGED
@@ -2,12 +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. The only thing it needs is an AI key,
6
- // which you bring yourself: export ANTHROPIC_API_KEY=sk-ant-... (or `ant auth login`).
5
+ // writes the update you'd actually send. It writes with Claude, so it needs an
6
+ // Anthropic API key — set one up once with `symbiot login`.
7
7
  //
8
8
  // symbiot week your week, written up (default)
9
9
  // symbiot standup yesterday + today, for standup
10
10
  // symbiot todo what's still on your plate
11
+ // symbiot login connect it to Claude (once)
12
+ // symbiot whoami show how it's connected
11
13
  // symbiot help
12
14
  //
13
15
  // Flags: --dir <path> where to look (default: your home folder)
@@ -17,10 +19,15 @@
17
19
  import Anthropic from "@anthropic-ai/sdk";
18
20
  import { execSync } from "node:child_process";
19
21
  import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
24
+ import { createInterface } from "node:readline";
20
25
 
21
- const MODEL = process.env.SYMBIOT_MODEL || "claude-opus-5";
26
+ const MODEL = process.env.SYMBIOT_MODEL || "claude-opus-5-5";
22
27
  const MAX_REPOS = 14;
23
28
  const MAX_COMMITS = 140;
29
+ const CONFIG_DIR = join(homedir(), ".config", "symbiot");
30
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
24
31
 
25
32
  // ---- tiny arg parse --------------------------------------------------------
26
33
  const argv = process.argv.slice(2);
@@ -49,6 +56,60 @@ function spinner(label) {
49
56
  return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
50
57
  }
51
58
 
59
+ // ---- auth ------------------------------------------------------------------
60
+ // Symbiot writes with Claude. Credentials can come from (first wins):
61
+ // 1. ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN in the environment
62
+ // 2. a key saved by `symbiot login` (~/.config/symbiot/config.json)
63
+ // 3. an Anthropic CLI profile on disk (`ant auth login`), which the SDK reads
64
+ function loadConfig() {
65
+ try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
66
+ }
67
+ function saveConfig(cfg) {
68
+ try {
69
+ mkdirSync(CONFIG_DIR, { recursive: true });
70
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
71
+ try { chmodSync(CONFIG_PATH, 0o600); } catch {}
72
+ return true;
73
+ } catch { return false; }
74
+ }
75
+ function antProfileExists() {
76
+ try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
77
+ }
78
+ // Returns { opts, source } for building the client, or null if nothing is set up.
79
+ function resolveAuth() {
80
+ if (process.env.ANTHROPIC_API_KEY) return { opts: {}, source: "ANTHROPIC_API_KEY (environment)" };
81
+ if (process.env.ANTHROPIC_AUTH_TOKEN) return { opts: {}, source: "ANTHROPIC_AUTH_TOKEN (environment)" };
82
+ const cfg = loadConfig();
83
+ if (cfg.apiKey) return { opts: { apiKey: cfg.apiKey }, source: "your saved login (~/.config/symbiot)" };
84
+ if (antProfileExists()) return { opts: {}, source: "your Anthropic CLI login (ant auth login)" };
85
+ return null;
86
+ }
87
+ function getClient() {
88
+ const auth = resolveAuth();
89
+ if (!auth) return null;
90
+ try { return { client: new Anthropic(auth.opts), source: auth.source }; }
91
+ catch { return null; }
92
+ }
93
+
94
+ // ---- prompt (with masked secret input) ------------------------------------
95
+ function ask(question, { secret = false } = {}) {
96
+ return new Promise((resolve) => {
97
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
98
+ if (secret && !PLAIN) {
99
+ rl._writeToOutput = (str) => {
100
+ if (str.includes(question)) rl.output.write(question);
101
+ else if (str.includes("\n")) rl.output.write("\n");
102
+ else rl.output.write("*");
103
+ };
104
+ }
105
+ rl.question(question, (ans) => {
106
+ rl.close();
107
+ if (secret) process.stdout.write("\n");
108
+ resolve((ans || "").trim());
109
+ });
110
+ });
111
+ }
112
+
52
113
  // ---- git ------------------------------------------------------------------
53
114
  function sh(cmd) {
54
115
  try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }); }
@@ -120,29 +181,32 @@ function openWork(repos) {
120
181
 
121
182
  // ---- claude ---------------------------------------------------------------
122
183
  async function write(system, prompt) {
123
- let client;
124
- try { client = new Anthropic(); } catch { client = null; }
184
+ const conn = getClient();
185
+ if (!conn) { console.log(AUTH_HELP); return null; }
125
186
  const stop = spinner("thinking…");
126
187
  try {
127
- const res = await client.messages.create({
188
+ const res = await conn.client.messages.create({
128
189
  model: MODEL, max_tokens: 1600,
190
+ output_config: { effort: "low" }, // a summary doesn't need deep reasoning — keeps it fast + cheap
129
191
  system, messages: [{ role: "user", content: prompt }],
130
192
  });
131
193
  if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
132
194
  return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
133
195
  } catch (err) {
134
- if (err instanceof Anthropic.AuthenticationError || /api key|ANTHROPIC_API_KEY|credential/i.test(err?.message || "")) {
135
- return AUTH_HELP;
196
+ if (err instanceof Anthropic.AuthenticationError || /api key|ANTHROPIC_API_KEY|authentication|credential|401/i.test(err?.message || "")) {
197
+ console.log(c.y("Your Claude key was rejected. ") + "Re-connect with: " + c.b("symbiot login --force"));
198
+ return null;
136
199
  }
137
200
  return `Couldn't reach the model: ${err?.message || err}`;
138
201
  } finally { stop(); }
139
202
  }
140
203
 
141
- const AUTH_HELP = c.y("Symbiot needs an AI key to write your update.\n") +
142
- " Set one of these, then run again:\n" +
143
- " export ANTHROPIC_API_KEY=sk-ant-...\n" +
144
- " or: ant auth login\n" +
145
- c.d(" (This is the only key it needs — no Google, no integrations.)");
204
+ const AUTH_HELP =
205
+ c.y("Symbiot writes your updates with Claude, so it needs an Anthropic API key.\n") +
206
+ " Fastest: " + c.b("symbiot login") + c.d(" paste a key once; saved to ~/.config/symbiot") + "\n" +
207
+ " Or set: export ANTHROPIC_API_KEY=sk-ant-...\n" +
208
+ " Get a key: https://console.anthropic.com/settings/keys\n" +
209
+ c.d(" (If you use the Anthropic CLI, `ant auth login` works too.)");
146
210
 
147
211
  // ---- render ---------------------------------------------------------------
148
212
  function renderCommits(list) {
@@ -158,6 +222,7 @@ const SINCE_WEEK = Number(flag("since", "7"));
158
222
  const BASE = flag("dir", homedir());
159
223
 
160
224
  async function cmdWeek(days, label) {
225
+ if (!resolveAuth()) { console.log(AUTH_HELP); return; }
161
226
  const who = me();
162
227
  const repos = findRepos(BASE, days);
163
228
  if (!repos.length) {
@@ -186,11 +251,12 @@ async function cmdWeek(days, label) {
186
251
  `Write the ${label === "standup" ? "standup" : "update"}.`;
187
252
 
188
253
  header("Symbiot", `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`);
189
- console.log(await write(system, prompt));
190
- console.log("");
254
+ const out = await write(system, prompt);
255
+ if (out) console.log(out + "\n");
191
256
  }
192
257
 
193
258
  async function cmdTodo() {
259
+ if (!resolveAuth()) { console.log(AUTH_HELP); return; }
194
260
  const repos = findRepos(BASE, 60);
195
261
  const open = openWork(repos);
196
262
  if (!open.length) { console.log(c.y("Nothing outstanding found (no TODOs or uncommitted work).")); return; }
@@ -199,8 +265,67 @@ async function cmdTodo() {
199
265
  `Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
200
266
  `Be concise and concrete. No preamble.`;
201
267
  header("Symbiot", `${open.length} open items · todo`);
202
- console.log(await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`));
203
- console.log("");
268
+ const out = await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`);
269
+ if (out) console.log(out + "\n");
270
+ }
271
+
272
+ async function cmdLogin() {
273
+ const existing = resolveAuth();
274
+ const provided = flag("key", null);
275
+ if (existing && !provided && !has("force")) {
276
+ console.log(c.g("✓ ") + `Already connected — Symbiot is using ${existing.source}.`);
277
+ console.log(c.d(" Replace it with `symbiot login --force`, or just run `symbiot week`."));
278
+ return;
279
+ }
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
+
285
+ let key = provided;
286
+ if (!key) key = await ask("Paste your Anthropic API key (sk-ant-…): ", { secret: true });
287
+ key = (key || "").trim();
288
+ if (!key) { console.log(c.y("No key entered — nothing saved.")); return; }
289
+ if (!/^sk-ant-/.test(key)) console.log(c.d("(that doesn't look like an sk-ant- key, but I'll try it)"));
290
+
291
+ const stop = spinner("checking the key…");
292
+ let ok = false, why = "";
293
+ try { await new Anthropic({ apiKey: key }).models.list(); ok = true; }
294
+ catch (e) {
295
+ if (e instanceof Anthropic.AuthenticationError || /401|invalid|authentication/i.test(e?.message || ""))
296
+ why = "Anthropic rejected it — the key looks invalid.";
297
+ else why = `couldn't reach Anthropic — ${e?.message || e}`;
298
+ }
299
+ finally { stop(); }
300
+
301
+ if (!ok) {
302
+ console.log(c.y("That key didn't work: ") + why);
303
+ console.log(c.d("Double-check it and run `symbiot login` again."));
304
+ process.exitCode = 1;
305
+ return;
306
+ }
307
+ if (saveConfig({ ...loadConfig(), apiKey: key })) {
308
+ console.log(c.g("✓ ") + "Connected. You're set — try: " + c.b("symbiot week"));
309
+ console.log(c.d(` Key saved in ${CONFIG_PATH} (readable only by you).`));
310
+ } else {
311
+ console.log(c.y("Couldn't write the config file. Set it in your shell instead:"));
312
+ console.log(" export ANTHROPIC_API_KEY=" + key);
313
+ }
314
+ }
315
+
316
+ function cmdLogout() {
317
+ const cfg = loadConfig();
318
+ if (!cfg.apiKey) { console.log("No saved key to remove."); return; }
319
+ delete cfg.apiKey;
320
+ saveConfig(cfg);
321
+ console.log(c.g("✓ ") + `Removed the saved key from ${CONFIG_PATH}.`);
322
+ if (process.env.ANTHROPIC_API_KEY) console.log(c.d("Note: ANTHROPIC_API_KEY is still set in your environment."));
323
+ }
324
+
325
+ function cmdWhoami() {
326
+ const a = resolveAuth();
327
+ if (a) console.log(c.g("✓ ") + `Symbiot is connected — using ${a.source}. Model: ${MODEL}`);
328
+ else { console.log(c.y("Not connected yet.\n")); console.log(AUTH_HELP); }
204
329
  }
205
330
 
206
331
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
@@ -209,15 +334,21 @@ ${c.b("Usage")}
209
334
  symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
210
335
  symbiot standup yesterday + today, for standup
211
336
  symbiot todo what's still on your plate
337
+ symbiot login connect it to Claude (once)
338
+ symbiot whoami show how it's connected
339
+ symbiot logout forget the saved key
212
340
  symbiot help
213
341
 
214
342
  ${c.b("Options")}
215
343
  --dir <path> where your repos are (default: ${homedir()})
216
344
  --since <days> window for 'week' (default 7)
345
+ --all everyone's commits, not just yours
217
346
  --plain no colour/spinner (good for piping)
218
347
 
219
- ${c.b("Setup")} one key, nothing else:
220
- export ANTHROPIC_API_KEY=sk-ant-... ${c.d("(or: ant auth login)")}
348
+ ${c.b("Setup")} it writes with Claude, so it needs one Anthropic key:
349
+ symbiot login ${c.d("paste a key once (recommended)")}
350
+ export ANTHROPIC_API_KEY=sk-ant-... ${c.d("or set it yourself")}
351
+ ${c.d("Change the model with SYMBIOT_MODEL (default " + MODEL + "; e.g. claude-haiku-4-5 is cheaper).")}
221
352
 
222
353
  ${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
223
354
  ${c.d("commit summaries sent to the model to write your update.")}`;
@@ -225,6 +356,9 @@ ${c.d("commit summaries sent to the model to write your update.")}`;
225
356
  // ---- main -----------------------------------------------------------------
226
357
  (async () => {
227
358
  if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
359
+ if (cmd === "login" || cmd === "auth") return cmdLogin();
360
+ if (cmd === "logout") return cmdLogout();
361
+ if (cmd === "whoami" || cmd === "status") return cmdWhoami();
228
362
  if (cmd === "week") return cmdWeek(SINCE_WEEK, "week");
229
363
  if (cmd === "standup") return cmdWeek(2, "standup");
230
364
  if (cmd === "todo") return cmdTodo();
package/package.json CHANGED
@@ -1,13 +1,40 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Your week, written from your real work. Reads your local git — no accounts, no OAuth, just an AI key you bring yourself.",
5
5
  "type": "module",
6
- "bin": { "symbiot": "index.mjs" },
7
- "files": ["index.mjs", "README.md", "LICENSE"],
8
- "engines": { "node": ">=18" },
9
- "keywords": ["git", "standup", "weekly-update", "changelog", "productivity", "ai", "developer-tools", "cli"],
6
+ "bin": {
7
+ "symbiot": "index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "git",
19
+ "standup",
20
+ "weekly-update",
21
+ "changelog",
22
+ "productivity",
23
+ "ai",
24
+ "developer-tools",
25
+ "cli"
26
+ ],
10
27
  "author": "Garth White <garth@ghost-ai.co.za>",
11
28
  "license": "MIT",
12
- "dependencies": { "@anthropic-ai/sdk": "^0.128.0" }
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/GarthGhostai/symbiot.git"
32
+ },
33
+ "homepage": "https://github.com/GarthGhostai/symbiot#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/GarthGhostai/symbiot/issues"
36
+ },
37
+ "dependencies": {
38
+ "@anthropic-ai/sdk": "^0.128.0"
39
+ }
13
40
  }