symbiot 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/index.mjs +232 -0
- package/package.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Garth White (GhostAI)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# symbiot
|
|
2
|
+
|
|
3
|
+
Your week, written from your real work.
|
|
4
|
+
|
|
5
|
+
Symbiot reads your **local git activity** and writes the update you'd actually send —
|
|
6
|
+
your weekly summary, your standup, or what's still on your plate. No accounts, no
|
|
7
|
+
OAuth, no integrations to wire up. The only thing it needs is an AI key, which you
|
|
8
|
+
bring yourself.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
symbiot your last 7 days, written up (same as: symbiot week)
|
|
12
|
+
symbiot standup yesterday + today, for standup
|
|
13
|
+
symbiot todo what's still on your plate
|
|
14
|
+
symbiot help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g symbiot # or run without installing: npx symbiot week
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then give it an AI key (this is the only credential it needs):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or: ant auth login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd ~/your/projects # or use --dir
|
|
33
|
+
symbiot week # writes up what you did, grouped and readable
|
|
34
|
+
symbiot week --since 14 # a fortnight instead of a week
|
|
35
|
+
symbiot standup # short: done + next
|
|
36
|
+
symbiot todo # TODOs + uncommitted work, prioritised
|
|
37
|
+
symbiot week --all # everyone's commits, not just yours
|
|
38
|
+
symbiot week --plain # no colour/spinner, good for piping
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By default it looks under your home folder for repos with recent commits, and
|
|
42
|
+
summarises the commits you authored (the email you commit under in each repo).
|
|
43
|
+
Point it somewhere specific with `--dir ~/work`.
|
|
44
|
+
|
|
45
|
+
## What it reads, and what it doesn't
|
|
46
|
+
|
|
47
|
+
- **Reads:** your local git — commit messages and changed-file names, plus
|
|
48
|
+
`TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
|
|
49
|
+
- **Sends to the model:** only those commit/TODO summaries, so it can write the
|
|
50
|
+
update. Not your code, not file contents.
|
|
51
|
+
- **Never:** no keylogging, no screen capture, no browsing history, no accounts.
|
|
52
|
+
|
|
53
|
+
## Config
|
|
54
|
+
|
|
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`.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
Part of Symbiot — an assistant that learns how you work and gives it back to you.
|
|
61
|
+
This CLI is the personal, install-it-yourself front door: it needs nothing but your
|
|
62
|
+
own machine and one key.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// symbiot — your week, written from your real work.
|
|
3
|
+
//
|
|
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`).
|
|
7
|
+
//
|
|
8
|
+
// symbiot week your week, written up (default)
|
|
9
|
+
// symbiot standup yesterday + today, for standup
|
|
10
|
+
// symbiot todo what's still on your plate
|
|
11
|
+
// symbiot help
|
|
12
|
+
//
|
|
13
|
+
// Flags: --dir <path> where to look (default: your home folder)
|
|
14
|
+
// --since <n> days back for `week` (default 7)
|
|
15
|
+
// --plain no colour, no spinner (for piping)
|
|
16
|
+
|
|
17
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
|
|
21
|
+
const MODEL = process.env.SYMBIOT_MODEL || "claude-opus-5";
|
|
22
|
+
const MAX_REPOS = 14;
|
|
23
|
+
const MAX_COMMITS = 140;
|
|
24
|
+
|
|
25
|
+
// ---- tiny arg parse --------------------------------------------------------
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const cmd = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "week").toLowerCase();
|
|
28
|
+
const flag = (name, def) => {
|
|
29
|
+
const i = argv.indexOf(`--${name}`);
|
|
30
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
|
31
|
+
};
|
|
32
|
+
const has = (name) => argv.includes(`--${name}`);
|
|
33
|
+
const PLAIN = has("plain") || !process.stdout.isTTY;
|
|
34
|
+
|
|
35
|
+
// ---- colour + spinner ------------------------------------------------------
|
|
36
|
+
const c = PLAIN
|
|
37
|
+
? { g: (s) => s, d: (s) => s, b: (s) => s, y: (s) => s }
|
|
38
|
+
: {
|
|
39
|
+
g: (s) => `\x1b[38;5;42m${s}\x1b[0m`, // green
|
|
40
|
+
d: (s) => `\x1b[38;5;66m${s}\x1b[0m`, // faint
|
|
41
|
+
b: (s) => `\x1b[1m${s}\x1b[0m`, // bold
|
|
42
|
+
y: (s) => `\x1b[38;5;179m${s}\x1b[0m`, // amber
|
|
43
|
+
};
|
|
44
|
+
function spinner(label) {
|
|
45
|
+
if (PLAIN) { process.stderr.write(label + "\n"); return () => {}; }
|
|
46
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
47
|
+
let i = 0;
|
|
48
|
+
const t = setInterval(() => process.stderr.write(`\r${c.g(frames[i++ % frames.length])} ${c.d(label)} `), 80);
|
|
49
|
+
return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- git ------------------------------------------------------------------
|
|
53
|
+
function sh(cmd) {
|
|
54
|
+
try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }); }
|
|
55
|
+
catch { return ""; }
|
|
56
|
+
}
|
|
57
|
+
function me() {
|
|
58
|
+
return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
|
|
59
|
+
}
|
|
60
|
+
function findRepos(base, sinceDays) {
|
|
61
|
+
const out = sh(
|
|
62
|
+
`find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
|
|
63
|
+
`| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -400`,
|
|
64
|
+
);
|
|
65
|
+
const repos = [];
|
|
66
|
+
for (const g of out.split("\n").filter(Boolean)) {
|
|
67
|
+
const repo = g.replace(/\/\.git$/, "");
|
|
68
|
+
const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
|
|
69
|
+
const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
|
|
70
|
+
if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
|
|
71
|
+
}
|
|
72
|
+
return repos.sort((a, b) => b.recency - a.recency).slice(0, MAX_REPOS);
|
|
73
|
+
}
|
|
74
|
+
function commits(repos, sinceExpr, mineOnly = true) {
|
|
75
|
+
const all = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const r of repos) {
|
|
78
|
+
// Filter to the identity you actually commit under IN THIS repo (people use
|
|
79
|
+
// different emails per project); no filter with --all.
|
|
80
|
+
const email = mineOnly ? sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim() : "";
|
|
81
|
+
const authorArg = email ? `--author=${JSON.stringify(email)}` : "";
|
|
82
|
+
const raw = sh(
|
|
83
|
+
`git -C ${JSON.stringify(r.path)} log --since=${JSON.stringify(sinceExpr)} ${authorArg} ` +
|
|
84
|
+
`--no-merges --date=short --name-only --pretty=format:'@@@%H|%ad|%s' -n 60`,
|
|
85
|
+
);
|
|
86
|
+
let cur = null;
|
|
87
|
+
const flush = () => {
|
|
88
|
+
if (cur && !seen.has(cur.h)) { seen.add(cur.h); all.push({ ...cur, repo: r.name }); }
|
|
89
|
+
};
|
|
90
|
+
for (const line of raw.split("\n")) {
|
|
91
|
+
if (line.startsWith("@@@")) {
|
|
92
|
+
flush();
|
|
93
|
+
const [h, date, subject] = line.slice(3).split("|");
|
|
94
|
+
cur = { h, date, subject: subject || "", files: [] };
|
|
95
|
+
} else if (line.trim() && cur) cur.files.push(line.trim());
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
}
|
|
99
|
+
return all.slice(0, MAX_COMMITS);
|
|
100
|
+
}
|
|
101
|
+
function openWork(repos) {
|
|
102
|
+
const items = [];
|
|
103
|
+
for (const r of repos) {
|
|
104
|
+
const todos = sh(
|
|
105
|
+
`git -C ${JSON.stringify(r.path)} grep -nEI '(TODO|FIXME|HACK)[:( ]' -- ` +
|
|
106
|
+
`'*.ts' '*.tsx' '*.js' '*.py' '*.go' '*.rs' '*.java' '*.sql' '*.vue' 2>/dev/null | head -8`,
|
|
107
|
+
);
|
|
108
|
+
for (const l of todos.split("\n").filter(Boolean)) {
|
|
109
|
+
const m = l.match(/^([^:]+):(\d+):(.*)$/);
|
|
110
|
+
if (m) items.push(`${r.name}: ${m[3].replace(/^[\s/*#-]+/, "").trim().slice(0, 120)} (${m[1]}:${m[2]})`);
|
|
111
|
+
}
|
|
112
|
+
const dirty = sh(`git -C ${JSON.stringify(r.path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
|
|
113
|
+
if (dirty.length) {
|
|
114
|
+
const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
|
|
115
|
+
items.push(`${r.name}: ${dirty.length} uncommitted file(s) on ${branch || "?"}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return items;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- claude ---------------------------------------------------------------
|
|
122
|
+
async function write(system, prompt) {
|
|
123
|
+
let client;
|
|
124
|
+
try { client = new Anthropic(); } catch { client = null; }
|
|
125
|
+
const stop = spinner("thinking…");
|
|
126
|
+
try {
|
|
127
|
+
const res = await client.messages.create({
|
|
128
|
+
model: MODEL, max_tokens: 1600,
|
|
129
|
+
system, messages: [{ role: "user", content: prompt }],
|
|
130
|
+
});
|
|
131
|
+
if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
|
|
132
|
+
return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof Anthropic.AuthenticationError || /api key|ANTHROPIC_API_KEY|credential/i.test(err?.message || "")) {
|
|
135
|
+
return AUTH_HELP;
|
|
136
|
+
}
|
|
137
|
+
return `Couldn't reach the model: ${err?.message || err}`;
|
|
138
|
+
} finally { stop(); }
|
|
139
|
+
}
|
|
140
|
+
|
|
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.)");
|
|
146
|
+
|
|
147
|
+
// ---- render ---------------------------------------------------------------
|
|
148
|
+
function renderCommits(list) {
|
|
149
|
+
return list.map((x) => `- [${x.repo}] ${x.subject}${x.files.length ? ` (${x.files.length} files)` : ""}`).join("\n");
|
|
150
|
+
}
|
|
151
|
+
function header(title, sub) {
|
|
152
|
+
if (PLAIN) { console.log(`${title}\n${sub}\n`); return; }
|
|
153
|
+
console.log(`\n${c.g("●")} ${c.b("Symbiot")} ${c.d("· " + sub)}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- commands -------------------------------------------------------------
|
|
157
|
+
const SINCE_WEEK = Number(flag("since", "7"));
|
|
158
|
+
const BASE = flag("dir", homedir());
|
|
159
|
+
|
|
160
|
+
async function cmdWeek(days, label) {
|
|
161
|
+
const who = me();
|
|
162
|
+
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.
|
|
169
|
+
let cs = commits(repos, `${days} days ago`, !has("all"));
|
|
170
|
+
if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
|
|
171
|
+
const open = label === "week" ? openWork(repos) : [];
|
|
172
|
+
|
|
173
|
+
if (!cs.length) { console.log(c.y("Found repos, but no commits in the window.")); return; }
|
|
174
|
+
|
|
175
|
+
const system =
|
|
176
|
+
`You write a short, first-person work update from a person's git commits. ` +
|
|
177
|
+
`Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
|
|
178
|
+
`Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
|
|
179
|
+
`${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
|
+
`No preamble, no sign-off, no invented work — only what the commits and open items show.`;
|
|
181
|
+
|
|
182
|
+
const prompt =
|
|
183
|
+
`Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
|
|
184
|
+
`Commits:\n${renderCommits(cs)}\n\n` +
|
|
185
|
+
(open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
|
|
186
|
+
`Write the ${label === "standup" ? "standup" : "update"}.`;
|
|
187
|
+
|
|
188
|
+
header("Symbiot", `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`);
|
|
189
|
+
console.log(await write(system, prompt));
|
|
190
|
+
console.log("");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function cmdTodo() {
|
|
194
|
+
const repos = findRepos(BASE, 60);
|
|
195
|
+
const open = openWork(repos);
|
|
196
|
+
if (!open.length) { console.log(c.y("Nothing outstanding found (no TODOs or uncommitted work).")); return; }
|
|
197
|
+
const system =
|
|
198
|
+
`You summarise what's still on a developer's plate from their TODO markers and uncommitted work. ` +
|
|
199
|
+
`Group by project, lead with what looks most in-flight (uncommitted work) then the to-dos. ` +
|
|
200
|
+
`Be concise and concrete. No preamble.`;
|
|
201
|
+
header("Symbiot", `${open.length} open items · todo`);
|
|
202
|
+
console.log(await write(system, `Open work:\n${open.map((o) => `- ${o}`).join("\n")}\n\nWhat's still on my plate?`));
|
|
203
|
+
console.log("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const HELP = `${c.b("symbiot")} — your week, written from your real work.
|
|
207
|
+
|
|
208
|
+
${c.b("Usage")}
|
|
209
|
+
symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
|
|
210
|
+
symbiot standup yesterday + today, for standup
|
|
211
|
+
symbiot todo what's still on your plate
|
|
212
|
+
symbiot help
|
|
213
|
+
|
|
214
|
+
${c.b("Options")}
|
|
215
|
+
--dir <path> where your repos are (default: ${homedir()})
|
|
216
|
+
--since <days> window for 'week' (default 7)
|
|
217
|
+
--plain no colour/spinner (good for piping)
|
|
218
|
+
|
|
219
|
+
${c.b("Setup")} one key, nothing else:
|
|
220
|
+
export ANTHROPIC_API_KEY=sk-ant-... ${c.d("(or: ant auth login)")}
|
|
221
|
+
|
|
222
|
+
${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
|
|
223
|
+
${c.d("commit summaries sent to the model to write your update.")}`;
|
|
224
|
+
|
|
225
|
+
// ---- main -----------------------------------------------------------------
|
|
226
|
+
(async () => {
|
|
227
|
+
if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
|
|
228
|
+
if (cmd === "week") return cmdWeek(SINCE_WEEK, "week");
|
|
229
|
+
if (cmd === "standup") return cmdWeek(2, "standup");
|
|
230
|
+
if (cmd === "todo") return cmdTodo();
|
|
231
|
+
console.log(c.y(`Unknown command: ${cmd}`) + "\n"); console.log(HELP);
|
|
232
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "symbiot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your week, written from your real work. Reads your local git — no accounts, no OAuth, just an AI key you bring yourself.",
|
|
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"],
|
|
10
|
+
"author": "Garth White <garth@ghost-ai.co.za>",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"dependencies": { "@anthropic-ai/sdk": "^0.128.0" }
|
|
13
|
+
}
|