scriptonia 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.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # scriptonia
2
+
3
+ Product memory for your coding agent. One command, browser sign-in, and your
4
+ agent reads what your customers actually asked for — with sources it can cite.
5
+
6
+ ## Quick start
7
+
8
+ ```sh
9
+ npx scriptonia login
10
+ ```
11
+
12
+ That's it. A browser opens, you sign in with Google, and:
13
+
14
+ - your project is created (with a sample dataset so you can try it immediately),
15
+ - an API key is minted and stored for you (you never see or paste a key),
16
+ - the Claude Code skill is installed to `~/.claude/skills/scriptonia/`.
17
+
18
+ Then your agent — or you — can ask the product brain:
19
+
20
+ ```sh
21
+ scriptonia query "audit log filtering"
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | command | what it does |
27
+ |---|---|
28
+ | `scriptonia login` | Browser sign-in. Stores creds in `~/.scriptonia/config.json`, installs the skill. |
29
+ | `scriptonia query "<topic>"` | Ask the product brain. Flags: `--window 7d\|30d\|90d\|all`, `--segment enterprise\|mid_market\|smb\|free`, `--json`. |
30
+ | `scriptonia link "<signal_id>"` | Resolve a signal id (from `query` sources) to its full text. |
31
+ | `scriptonia status` | Who you are / which project. |
32
+ | `scriptonia logout` | Forget credentials. |
33
+ | `scriptonia mcp` | Run as an MCP server over stdio (for MCP-native agents like Codex). |
34
+
35
+ ## For MCP-native agents
36
+
37
+ Point your agent at:
38
+
39
+ ```json
40
+ {
41
+ "command": "npx",
42
+ "args": ["-y", "scriptonia", "mcp"]
43
+ }
44
+ ```
45
+
46
+ It exposes `get_context`, `link_back`, and `flag_contradiction`, reading your
47
+ stored credentials — no env vars needed after `scriptonia login`.
48
+
49
+ ## Self-hosting
50
+
51
+ By default the CLI talks to `https://scriptonia.dev`. Point it elsewhere with
52
+ `SCRIPTONIA_URL=https://your.host scriptonia login` (or `--url`).
53
+
54
+ Requires Node 18+.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scriptonia — product memory for your coding agent.
4
+ *
5
+ * scriptonia login browser sign-in; stores creds; installs the skill
6
+ * scriptonia query "<topic>" ask the product brain (what the agent calls)
7
+ * scriptonia link "<signal_id>" resolve a signal to its full text
8
+ * scriptonia status who am I / which project
9
+ * scriptonia logout forget creds
10
+ * scriptonia mcp speak MCP over stdio (for MCP-native agents)
11
+ *
12
+ * After `login`, everything lives in ~/.scriptonia/config.json. No env vars,
13
+ * no key pasting. The installed Claude Code skill just runs `scriptonia query`.
14
+ *
15
+ * Pure ESM, zero build step — runs on Node 18+.
16
+ */
17
+ import fs from "node:fs";
18
+ import http from "node:http";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { spawn } from "node:child_process";
22
+ import { randomBytes } from "node:crypto";
23
+
24
+ const DEFAULT_URL = process.env.SCRIPTONIA_URL || "https://scriptonia.dev";
25
+ const CONFIG_DIR = path.join(os.homedir(), ".scriptonia");
26
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
27
+
28
+ const [, , cmd, ...rest] = process.argv;
29
+
30
+ const commands = { login, query, link, status, logout, init: reinstallSkill, mcp, help: async () => usage() };
31
+ const run = commands[cmd ?? "help"] ?? (async () => { usage(); process.exit(1); });
32
+ run(rest).catch((e) => {
33
+ console.error("error:", e instanceof Error ? e.message : String(e));
34
+ process.exit(1);
35
+ });
36
+
37
+ // ─────────────────────────────────────────────────────────────
38
+ // login — the one command
39
+ // ─────────────────────────────────────────────────────────────
40
+ async function login(args) {
41
+ const flags = parseFlags(args);
42
+ const url = (flags.url ?? DEFAULT_URL).replace(/\/$/, "");
43
+ const state = randomBytes(16).toString("hex");
44
+
45
+ const result = await new Promise((resolve, reject) => {
46
+ let timer;
47
+ const done = (fn) => { clearTimeout(timer); fn(); };
48
+ const server = http.createServer((req, res) => {
49
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
50
+ if (u.pathname !== "/callback") { res.writeHead(404); res.end(); return; }
51
+ const token = u.searchParams.get("token");
52
+ const returnedState = u.searchParams.get("state");
53
+ const email = u.searchParams.get("email") ?? "";
54
+ const project = u.searchParams.get("project") ?? "";
55
+ if (!token || returnedState !== state) {
56
+ res.writeHead(400, { "content-type": "text/html" });
57
+ res.end(pageHtml("Login failed", "State mismatch — run <code>scriptonia login</code> again."));
58
+ return;
59
+ }
60
+ res.writeHead(200, { "content-type": "text/html" });
61
+ res.end(pageHtml("You're in ✓", `Signed in as <b>${escapeHtml(email)}</b> · project <b>${escapeHtml(project)}</b>.<br>Return to your terminal.`));
62
+ done(() => { server.close(); resolve({ url, token, email, project }); });
63
+ });
64
+
65
+ server.listen(0, "127.0.0.1", () => {
66
+ const addr = server.address();
67
+ const port = typeof addr === "object" && addr ? addr.port : 0;
68
+ const authUrl =
69
+ `${url}/auth/cli?port=${port}&state=${state}` +
70
+ (flags.dev ? `&dev=1&email=${encodeURIComponent(flags.email ?? "you@example.com")}` : "");
71
+ console.log("\n Opening your browser to sign in…");
72
+ console.log(" If it doesn't open, visit:\n " + authUrl + "\n");
73
+ openBrowser(authUrl);
74
+ });
75
+
76
+ timer = setTimeout(() => { server.close(); reject(new Error("timed out waiting for browser login (120s)")); }, 120_000);
77
+ });
78
+
79
+ saveConfig(result);
80
+ installSkill();
81
+
82
+ console.log(`\n ✓ Signed in as ${result.email}`);
83
+ console.log(` ✓ Project "${result.project}" ready (with a sample dataset to try)`);
84
+ console.log(` ✓ Skill installed → ~/.claude/skills/scriptonia/SKILL.md`);
85
+ console.log(`\n Your agent now reads your customers. Try it:\n`);
86
+ console.log(` scriptonia query "audit log filtering"\n`);
87
+ }
88
+
89
+ // ─────────────────────────────────────────────────────────────
90
+ // query — what the agent (and you) call
91
+ // ─────────────────────────────────────────────────────────────
92
+ async function query(args) {
93
+ const cfg = requireConfig();
94
+ const flags = parseFlags(args);
95
+ const q = args.filter((a) => !a.startsWith("--")).join(" ").trim();
96
+ if (!q) fail('usage: scriptonia query "<topic>" [--window 30d] [--segment enterprise] [--json]');
97
+
98
+ const res = await fetch(`${cfg.url}/api/query`, {
99
+ method: "POST",
100
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
101
+ body: JSON.stringify({ query: q, window: flags.window, segment: flags.segment }),
102
+ });
103
+ const data = await res.json();
104
+ if (!res.ok) fail(`query failed (${res.status}): ${JSON.stringify(data)}`);
105
+
106
+ const wantJson = flags.json !== undefined || !process.stdout.isTTY;
107
+ if (wantJson) { process.stdout.write(JSON.stringify(data)); return; }
108
+ printPretty(data);
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────
112
+ // link — resolve a signal_id to its full body
113
+ // ─────────────────────────────────────────────────────────────
114
+ async function link(args) {
115
+ const cfg = requireConfig();
116
+ const id = args.find((a) => !a.startsWith("--"));
117
+ if (!id) fail('usage: scriptonia link "<signal_id>"');
118
+ const res = await fetch(`${cfg.url}/api/link_back?signal_id=${encodeURIComponent(id)}`, {
119
+ headers: { authorization: `Bearer ${cfg.token}` },
120
+ });
121
+ const data = await res.json();
122
+ if (!res.ok) fail(`link failed (${res.status}): ${JSON.stringify(data)}`);
123
+ process.stdout.write(JSON.stringify(data));
124
+ }
125
+
126
+ // ─────────────────────────────────────────────────────────────
127
+ // status / logout
128
+ // ─────────────────────────────────────────────────────────────
129
+ async function status() {
130
+ const cfg = loadConfig();
131
+ if (!cfg) { console.log("not logged in — run `scriptonia login`"); return; }
132
+ console.log(`signed in as ${cfg.email}`);
133
+ console.log(`project ${cfg.project}`);
134
+ console.log(`endpoint ${cfg.url}`);
135
+ console.log(`config ${CONFIG_PATH}`);
136
+ }
137
+
138
+ async function logout() {
139
+ if (fs.existsSync(CONFIG_PATH)) fs.rmSync(CONFIG_PATH);
140
+ console.log("logged out.");
141
+ }
142
+
143
+ // ─────────────────────────────────────────────────────────────
144
+ // skill install
145
+ // ─────────────────────────────────────────────────────────────
146
+ async function reinstallSkill() {
147
+ requireConfig();
148
+ installSkill();
149
+ console.log("✓ skill reinstalled → ~/.claude/skills/scriptonia/SKILL.md");
150
+ }
151
+
152
+ function installSkill() {
153
+ const dir = path.join(os.homedir(), ".claude", "skills", "scriptonia");
154
+ fs.mkdirSync(dir, { recursive: true });
155
+ fs.writeFileSync(path.join(dir, "SKILL.md"), skillMarkdown());
156
+ }
157
+
158
+ function skillMarkdown() {
159
+ return `---
160
+ name: scriptonia
161
+ description: >
162
+ BEFORE planning any user-facing change — a feature, a bug fix, a refactor with
163
+ UX impact, or a non-trivial default — run \`scriptonia query "<topic>"\`. It
164
+ returns what THIS project's customers actually asked for (with source ids you
165
+ can cite), what prior decisions your plan might contradict, and known edge
166
+ cases. Skipping it means shipping a plan that isn't grounded in customer signal.
167
+ ---
168
+
169
+ # Scriptonia — Product Memory
170
+
171
+ Before writing a plan for anything user-facing, call:
172
+
173
+ \`\`\`sh
174
+ scriptonia query "<the topic you're about to build>"
175
+ \`\`\`
176
+
177
+ Optional flags: \`--window 7d|30d|90d|all\` (default 30d), \`--segment enterprise|mid_market|smb|free\`.
178
+
179
+ Output (JSON when piped) contains:
180
+ - **requirements[]** — each with \`source_ids\` and a \`context_url\`. Cite the
181
+ source_ids next to the matching step in your plan.
182
+ - **contradictions[]** — if non-empty, STOP and surface it to the human before
183
+ writing code. Each has a \`decision_ref\`.
184
+ - **edge_cases[]** — fold these into the plan.
185
+ - **sources[]** — quote a customer verbatim with \`scriptonia link "<signal_id>"\`.
186
+
187
+ Rule: every plan step for a user-facing change should cite at least one
188
+ source_id. If it can't, you're inventing demand — query first.
189
+ `;
190
+ }
191
+
192
+ // ─────────────────────────────────────────────────────────────
193
+ // mcp — MCP stdio server (for MCP-native agents)
194
+ // ─────────────────────────────────────────────────────────────
195
+ async function mcp() {
196
+ const cfg = requireConfig();
197
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
198
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
199
+ const { z } = await import("zod");
200
+
201
+ const server = new McpServer({ name: "scriptonia", version: "0.2.0" }, { capabilities: { tools: {} } });
202
+ const auth = () => ({ authorization: `Bearer ${cfg.token}`, "content-type": "application/json" });
203
+
204
+ server.registerTool(
205
+ "get_context",
206
+ {
207
+ description:
208
+ "Query the product brain: what customers said about a topic, prior decisions your plan may contradict, and known edge cases. Call BEFORE planning any user-facing change.",
209
+ inputSchema: {
210
+ query: z.string(),
211
+ window: z.enum(["7d", "30d", "90d", "all"]).optional(),
212
+ segment: z.enum(["enterprise", "mid_market", "smb", "free", "unknown"]).optional(),
213
+ },
214
+ },
215
+ async ({ query: q, window, segment }) => {
216
+ const r = await fetch(`${cfg.url}/api/query`, { method: "POST", headers: auth(), body: JSON.stringify({ query: q, window, segment }) });
217
+ return { content: [{ type: "text", text: await r.text() }], isError: !r.ok };
218
+ },
219
+ );
220
+ server.registerTool(
221
+ "link_back",
222
+ { description: "Resolve a signal_id from get_context.sources[] to its full body and author.", inputSchema: { signal_id: z.string() } },
223
+ async ({ signal_id }) => {
224
+ const r = await fetch(`${cfg.url}/api/link_back?signal_id=${encodeURIComponent(signal_id)}`, { headers: { authorization: `Bearer ${cfg.token}` } });
225
+ return { content: [{ type: "text", text: await r.text() }], isError: !r.ok };
226
+ },
227
+ );
228
+ server.registerTool(
229
+ "flag_contradiction",
230
+ { description: "Report that your plan conflicts with a context. Persists for audit.", inputSchema: { plan_id: z.string(), context_id: z.string().optional(), note: z.string().optional() } },
231
+ async ({ plan_id, context_id, note }) => {
232
+ const r = await fetch(`${cfg.url}/api/flag_contradiction`, { method: "POST", headers: auth(), body: JSON.stringify({ plan_id, context_id, note }) });
233
+ return { content: [{ type: "text", text: await r.text() }], isError: !r.ok };
234
+ },
235
+ );
236
+
237
+ await server.connect(new StdioServerTransport());
238
+ }
239
+
240
+ // ─────────────────────────────────────────────────────────────
241
+ // helpers
242
+ // ─────────────────────────────────────────────────────────────
243
+ function loadConfig() {
244
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return null; }
245
+ }
246
+ function requireConfig() {
247
+ const cfg = loadConfig();
248
+ if (!cfg) fail("not logged in — run `scriptonia login`");
249
+ return cfg;
250
+ }
251
+ function saveConfig(cfg) {
252
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
253
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
254
+ }
255
+
256
+ function parseFlags(args) {
257
+ const out = {};
258
+ for (let i = 0; i < args.length; i++) {
259
+ const a = args[i];
260
+ if (!a.startsWith("--")) continue;
261
+ const eq = a.indexOf("=");
262
+ if (eq > 0) out[a.slice(2, eq)] = a.slice(eq + 1);
263
+ else {
264
+ const next = args[i + 1];
265
+ if (next && !next.startsWith("--")) out[a.slice(2)] = args[++i];
266
+ else out[a.slice(2)] = "true";
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function openBrowser(url) {
273
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
274
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
275
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* click the printed URL */ }
276
+ }
277
+
278
+ function printPretty(d) {
279
+ const c = { dim: "\x1b[2m", b: "\x1b[1m", v: "\x1b[35m", w: "\x1b[33m", g: "\x1b[32m", r: "\x1b[0m" };
280
+ console.log(`\n${c.b}${d.tldr ?? ""}${c.r}\n`);
281
+ for (const req of d.requirements ?? []) {
282
+ console.log(` ${c.g}●${c.r} ${c.b}${req.title}${c.r} ${c.dim}[${req.strength}]${c.r}`);
283
+ console.log(` ${req.detail}`);
284
+ console.log(` ${c.dim}sources: ${(req.source_ids ?? []).join(", ")}${c.r}`);
285
+ if (req.context_url) console.log(` ${c.v}${req.context_url}${c.r}`);
286
+ }
287
+ for (const con of d.contradictions ?? []) {
288
+ console.log(`\n ${c.w}⚠ CONTRADICTS${c.r} ${con.detail}`);
289
+ console.log(` ${c.dim}${con.decision_ref}${c.r}`);
290
+ }
291
+ if ((d.edge_cases ?? []).length) {
292
+ console.log(`\n ${c.dim}edge cases:${c.r}`);
293
+ for (const e of d.edge_cases) console.log(` · ${e}`);
294
+ }
295
+ console.log(`\n ${c.dim}${(d.sources ?? []).length} sources · ${d.meta?.ms ?? "?"}ms${c.r}\n`);
296
+ }
297
+
298
+ function pageHtml(title, body) {
299
+ return `<!doctype html><meta charset=utf8><body style="font-family:ui-monospace,monospace;background:#08080A;color:#EDEDF0;display:grid;place-items:center;height:100vh;margin:0">
300
+ <div style="text-align:center;max-width:420px">
301
+ <div style="font-size:28px;letter-spacing:-.02em;margin-bottom:12px">${title}</div>
302
+ <div style="color:#9a9aa5;line-height:1.6">${body}</div>
303
+ </div></body>`;
304
+ }
305
+ function escapeHtml(s) {
306
+ return s.replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
307
+ }
308
+ function fail(msg) { console.error(msg); process.exit(1); }
309
+ function usage() {
310
+ console.log(`scriptonia — product memory for your coding agent
311
+
312
+ scriptonia login sign in (browser) — installs the skill, no keys to copy
313
+ scriptonia query "<topic>" ask the product brain
314
+ scriptonia link "<id>" resolve a signal to its full text
315
+ scriptonia status show who you are
316
+ scriptonia logout forget credentials
317
+ scriptonia mcp run as an MCP server (stdio)
318
+
319
+ login flags: --dev --email you@co.com (local testing; skips Google)
320
+ query flags: --window 30d --segment enterprise --json
321
+ `);
322
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "scriptonia",
3
+ "version": "0.2.0",
4
+ "description": "Product memory for your coding agent. One command, browser sign-in, your agent reads your customers.",
5
+ "type": "module",
6
+ "bin": {
7
+ "scriptonia": "bin/scriptonia.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.29.0",
18
+ "zod": "^3.25.76"
19
+ },
20
+ "keywords": [
21
+ "scriptonia",
22
+ "claude-code",
23
+ "mcp",
24
+ "product-memory",
25
+ "customer-signal",
26
+ "agent",
27
+ "cli"
28
+ ],
29
+ "homepage": "https://scriptonia.dev",
30
+ "license": "MIT"
31
+ }