scribeclock 0.1.2 → 0.1.3

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 CHANGED
@@ -50,6 +50,7 @@ Run `scribeclock --help` for the full list and `scribeclock <command> --help` fo
50
50
  | Cloudflare | `cf accounts\|zones\|dns\|dns set\|dns rm\|dns diff\|pages\|deployments\|deploy-status\|workers\|purge\|request` |
51
51
  | Team | `team` · `overlap [files…]` (exit 4 on overlap) · `team report` · `handoff [record]` |
52
52
  | Git | `branch KEY [--create]` · `commit -m "…"` · `pr KEY` · `changelog --since <tag>` |
53
+ | Bug reports | `connect [--env] [--vault] [--create]` · `issues [show\|resolve --release X\|ignore\|reopen\|task]` · `bug "title" [-d …] [--log file] [--stack file]` · `ingest keys\|revoke\|test\|snippets` · `health [--days N]` |
53
54
  | Ops & agents | `doctor` · `watch tasks [--on-change cmd]` · `serve --stdio` · `mcp-config <client> [--install]` · `ask "…"` |
54
55
  | Raw tools | `tools` · `call <tool> [--arg v \| --args '{json}']` · `<tool_name> …` · `refresh` |
55
56
 
@@ -65,6 +66,19 @@ scribeclock cloudflare_dns_upsert --help # every tool
65
66
 
66
67
  ## Common workflows
67
68
 
69
+ **Connect a repository for bug & crash reports** (errors, crashes, user bug reports, trace logs and analytics from
70
+ the app become tasks on the project; docs: https://scribeclock.com/help/bug-reports):
71
+
72
+ ```bash
73
+ scribeclock connect --env # links this repo to its project, writes .scribeclock.json + SCRIBECLOCK_INGEST_KEY to .env
74
+ scribeclock ingest test # sends a test error straight to https://scribeclock.com/ingest → issue + task
75
+ scribeclock issues # what users hit: level, title, where, count, users, last seen, task
76
+ scribeclock issues show 3f2a9c1b # stack trace, breadcrumbs, trace logs, context
77
+ scribeclock bug "Checkout button does nothing" -d "Click Pay → nothing" --log logs/app.log
78
+ scribeclock issues resolve 3f2a9c1b --release 1.4.3 # a later hit in another release reopens it as a regression
79
+ scribeclock health --days 30
80
+ ```
81
+
68
82
  ```bash
69
83
  # Morning
70
84
  scribeclock today
@@ -11,6 +11,8 @@ export function sensitivity(name, def, args = {}) {
11
11
  }
12
12
  if (/^delete_/.test(name))
13
13
  return { confirm: true, reason: "deletes data", maskOutput: false, maskArgs: false };
14
+ if (/^revoke_/.test(name))
15
+ return { confirm: true, reason: "revokes a key (apps using it stop working)", maskOutput: false, maskArgs: false };
14
16
  if (VAULT_SENSITIVE.has(name) || (/^vault_/.test(name) && name !== "vault_list_secrets")) {
15
17
  const reads = name === "vault_get_env";
16
18
  return { confirm: true, reason: reads ? "reads vault values (audited)" : name.includes("delete") ? "wipes a vault secret" : "writes vault secrets", maskOutput: true, maskArgs: !reads };
@@ -11,6 +11,7 @@ export const GROUPS = [
11
11
  "Cloudflare",
12
12
  "Team",
13
13
  "Git",
14
+ "Bug reports",
14
15
  "Ops",
15
16
  "Automation & agents",
16
17
  "Raw tools",
@@ -14,6 +14,7 @@ import { WORKFLOW_COMMANDS } from "./workflow.js";
14
14
  import { GITFLOW_COMMANDS } from "./gitflow.js";
15
15
  import { OPS_COMMANDS } from "./ops.js";
16
16
  import { AUTOMATION_COMMANDS } from "./automation.js";
17
+ import { INGEST_COMMANDS } from "./ingest.js";
17
18
  /** Every built-in command. Server tools are added dynamically by main() from the catalog. */
18
19
  export function buildRegistry() {
19
20
  const registry = new Registry();
@@ -22,6 +23,7 @@ export function buildRegistry() {
22
23
  registry.add(...CLOCK_COMMANDS);
23
24
  registry.add(...TASK_COMMANDS);
24
25
  registry.add(...GITFLOW_COMMANDS);
26
+ registry.add(...INGEST_COMMANDS);
25
27
  registry.add(...PROJECT_COMMANDS);
26
28
  registry.add(...MEETING_COMMANDS);
27
29
  registry.add(...FILE_COMMANDS);
@@ -0,0 +1,771 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CliError, usageError } from "../cli/errors.js";
4
+ import { currentBranch, lastCommit, remoteUrl, repoRoot } from "../git.js";
5
+ import { EXIT } from "../types.js";
6
+ import { fmtDate, relTime } from "../util/format.js";
7
+ import { PROJECT_FLAG, cmd, emit, guardedEmit, list, num, personName, projectArg, requirePositional, str, taskKey } from "./_shared.js";
8
+ import { parseDotenv } from "./ops.js";
9
+ export const INGEST_URL = "https://scribeclock.com/ingest";
10
+ export const INGEST_DOCS_URL = "https://scribeclock.com/help/bug-reports";
11
+ const KEY_ENV = "SCRIBECLOCK_INGEST_KEY";
12
+ const REPO_FLAG = { name: "repo", alias: "r", type: "string", placeholder: "<owner/repo|url>", description: "Repository (default: this folder's git origin)." };
13
+ function repoOf(ctx, inv) {
14
+ return str(inv.flags.repo) ?? ctx.repo.config.repo ?? remoteUrl(ctx.cwd) ?? undefined;
15
+ }
16
+ /** `connect` writes it; every other command reads it back. */
17
+ function writeRepoConfig(ctx, patch) {
18
+ const root = repoRoot(ctx.cwd) ?? ctx.cwd;
19
+ const path = ctx.repo.path ?? join(root, ".scribeclock.json");
20
+ const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
21
+ const next = { ...current, ...patch };
22
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n");
23
+ ctx.repo = { path, config: next };
24
+ return path;
25
+ }
26
+ function upsertEnvLine(path, key, value) {
27
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
28
+ const current = parseDotenv(text);
29
+ if (current[key] === value)
30
+ return "same";
31
+ if (key in current) {
32
+ const re = new RegExp(`^(\\s*(?:export\\s+)?${key}\\s*=).*$`, "m");
33
+ writeFileSync(path, text.replace(re, `$1${value}`));
34
+ return "updated";
35
+ }
36
+ appendFileSync(path, `${text && !text.endsWith("\n") ? "\n" : ""}${key}=${value}\n`);
37
+ return "added";
38
+ }
39
+ function ensureGitignore(root, entry) {
40
+ const path = join(root, ".gitignore");
41
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
42
+ if (text.split(/\r?\n/).some((l) => l.trim() === entry || l.trim() === `/${entry}` || l.trim() === `${entry}*` || l.trim() === ".env*"))
43
+ return false;
44
+ appendFileSync(path, `${text && !text.endsWith("\n") ? "\n" : ""}${entry}\n`);
45
+ return true;
46
+ }
47
+ export function ingestKeyFor(ctx, flagKey) {
48
+ if (flagKey)
49
+ return flagKey;
50
+ const envName = ctx.repo.config.ingest?.key_env ?? KEY_ENV;
51
+ if (ctx.env[envName])
52
+ return String(ctx.env[envName]);
53
+ const root = repoRoot(ctx.cwd) ?? ctx.cwd;
54
+ for (const f of [".env", ".env.local"]) {
55
+ const p = join(root, f);
56
+ if (!existsSync(p))
57
+ continue;
58
+ const v = parseDotenv(readFileSync(p, "utf8"))[envName];
59
+ if (v)
60
+ return v;
61
+ }
62
+ return null;
63
+ }
64
+ /** Copy-paste snippets for the languages people connect from. */
65
+ export function ingestSnippets(key) {
66
+ const masked = key;
67
+ return [
68
+ {
69
+ id: "browser",
70
+ label: "Browser (one script tag)",
71
+ code: `<script src="https://scribeclock.com/sdk/scribeclock.js" data-key="${masked}" data-environment="production" data-release="1.0.0"></script>
72
+ <!-- Uncaught errors, unhandled promise rejections and console.error become issues automatically.
73
+ ScribeClock.captureException(err) · ScribeClock.reportBug({ title, description, email }) · ScribeClock.track("checkout_started") -->`,
74
+ },
75
+ {
76
+ id: "node",
77
+ label: "Node / backend (fetch)",
78
+ code: `const SCRIBECLOCK = { url: "${INGEST_URL}", key: process.env.${KEY_ENV} };
79
+ export async function reportError(err, extra = {}) {
80
+ await fetch(SCRIBECLOCK.url, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json", Authorization: \`Bearer \${SCRIBECLOCK.key}\` },
83
+ body: JSON.stringify({ type: "error", exception: { type: err.name, value: err.message, stacktrace: err.stack },
84
+ environment: process.env.NODE_ENV, release: process.env.APP_VERSION, platform: "node", ...extra }),
85
+ }).catch(() => {});
86
+ }
87
+ process.on("uncaughtException", (e) => reportError(e, { level: "fatal" }));
88
+ process.on("unhandledRejection", (e) => reportError(e instanceof Error ? e : new Error(String(e))));`,
89
+ },
90
+ {
91
+ id: "react-native",
92
+ label: "React Native / Expo",
93
+ code: `import { Platform } from "react-native";
94
+ const INGEST = { url: "${INGEST_URL}", key: process.env.EXPO_PUBLIC_${KEY_ENV} };
95
+ const send = (body) => fetch(INGEST.url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: \`Bearer \${INGEST.key}\` }, body: JSON.stringify(body) }).catch(() => {});
96
+ const previous = ErrorUtils.getGlobalHandler();
97
+ ErrorUtils.setGlobalHandler((error, isFatal) => {
98
+ send({ type: isFatal ? "crash" : "error", exception: { type: error.name, value: error.message, stacktrace: error.stack }, platform: Platform.OS, release: "1.0.0" });
99
+ previous?.(error, isFatal);
100
+ });
101
+ // From a "Report a problem" screen:
102
+ // send({ type: "bug_report", title, description, reporter: { email }, attachments: [screenshotUrl] });`,
103
+ },
104
+ {
105
+ id: "python",
106
+ label: "Python",
107
+ code: `import os, sys, traceback, requests
108
+ INGEST = "${INGEST_URL}"; KEY = os.environ["${KEY_ENV}"]
109
+ def report(exc, level="error", **extra):
110
+ requests.post(INGEST, json={"type": "error", "level": level, "exception": {"type": type(exc).__name__, "value": str(exc),
111
+ "stacktrace": "".join(traceback.format_exception(exc))}, "platform": "python", **extra},
112
+ headers={"Authorization": f"Bearer {KEY}"}, timeout=5)
113
+ sys.excepthook = lambda t, v, tb: report(v, level="fatal")`,
114
+ },
115
+ {
116
+ id: "curl",
117
+ label: "curl / CI",
118
+ code: `curl -sS ${INGEST_URL} -H "Authorization: Bearer $${KEY_ENV}" -H "Content-Type: application/json" \\
119
+ -d '{"type":"bug_report","title":"Checkout button does nothing","description":"Steps: …","reporter":{"email":"qa@example.com"},"release":"1.4.2"}'`,
120
+ },
121
+ ];
122
+ }
123
+ // ───────────────────────────── connect ─────────────────────────────
124
+ export const connectCommand = cmd({
125
+ path: "connect",
126
+ group: "Getting started",
127
+ summary: "Connect this repository to ScribeClock in one step: link it to a project and get the ingest key for bug / crash reports.",
128
+ description: "Detects the repository from git, links it to the project that already lists it (or --project, or --create for a new one), and returns the project's ingest key. Writes .scribeclock.json (project, org, repo, ingest endpoint) so every other command knows the project, and can put SCRIBECLOCK_INGEST_KEY into .env (--env) and the project vault (--vault). With the key your app posts errors, crashes, bug reports, trace logs and analytics to https://scribeclock.com/ingest, and each new issue opens a task (and a GitHub issue when the organization has GitHub connected). Needs org admin/staff, project admin or project lead.",
129
+ tool: ["connect_repo", "vault_put_env"],
130
+ positionals: [{ name: "repo", description: "owner/repo or clone URL (default: git remote origin)." }],
131
+ flags: [
132
+ PROJECT_FLAG,
133
+ { name: "create", alias: "c", type: "boolean", description: "Create the project when none links this repository (admins/staff)." },
134
+ { name: "name", type: "string", description: "Project name with --create (default: the repository name)." },
135
+ { name: "label", type: "string", description: "Name for the ingest key (default: the repository name)." },
136
+ { name: "environment", alias: "e", type: "string", description: "Default environment for events that omit one (production, staging…)." },
137
+ { name: "platform", type: "string", description: "web, ios, android, react-native, expo, node, python…" },
138
+ { name: "env", type: "boolean", description: "Write SCRIBECLOCK_INGEST_KEY into .env (added to .gitignore)." },
139
+ { name: "env-file", type: "string", placeholder: "<path>", description: "Dotenv file to write instead of .env (implies --env)." },
140
+ { name: "vault", type: "boolean", description: "Also store the key in the project vault as a secret (vault_put_env)." },
141
+ { name: "no-config", type: "boolean", description: "Do not write .scribeclock.json." },
142
+ { name: "no-github-issues", type: "boolean", description: "Do not open GitHub issues for new problems." },
143
+ { name: "rotate", type: "boolean", description: "Mint a new key instead of reusing the existing one." },
144
+ { name: "snippets", type: "boolean", description: "Print SDK snippets for every language." },
145
+ ],
146
+ examples: ["scribeclock connect", "scribeclock connect --project 'Kisa Print' --env", "scribeclock connect --create --name 'Dekki app' --platform expo", "scribeclock connect acme/api --env-file .env.production -e production --vault"],
147
+ async run(ctx, inv) {
148
+ const repo = inv.positionals[0] ?? repoOf(ctx, inv);
149
+ if (!repo)
150
+ throw usageError("Not in a git repository with an origin remote.", "Pass the repository: scribeclock connect owner/repo");
151
+ const project = projectArg(ctx, inv);
152
+ const args = {
153
+ repo,
154
+ project_id: project,
155
+ create_project: inv.flags.create ? true : undefined,
156
+ project_name: str(inv.flags.name),
157
+ label: str(inv.flags.label),
158
+ environment: str(inv.flags.environment),
159
+ platform: str(inv.flags.platform),
160
+ auto_github_issue: inv.flags["no-github-issues"] ? false : undefined,
161
+ rotate_key: inv.flags.rotate ? true : undefined,
162
+ };
163
+ if (ctx.flags.dryRun)
164
+ return emit(ctx, { ok: true, dry_run: true, tool: "connect_repo", arguments: args }, () => ctx.out.log(`${ctx.out.dim("dry run →")} connect_repo ${JSON.stringify(args)}`));
165
+ const data = await ctx.call("connect_repo", args);
166
+ const key = (data.ingest_key ?? {});
167
+ const keyValue = String(key.key ?? "");
168
+ const proj = (data.project ?? {});
169
+ const written = {};
170
+ if (!inv.flags["no-config"]) {
171
+ const cfg = (data.repo_config ?? {});
172
+ written.config = writeRepoConfig(ctx, { project: String(proj.id ?? cfg.project ?? ""), org: String(proj.organization_id ?? cfg.org ?? ""), repo: String(data.repo ?? repo), ingest: cfg.ingest ?? { url: INGEST_URL, key_env: KEY_ENV } });
173
+ }
174
+ if ((inv.flags.env || str(inv.flags["env-file"])) && keyValue) {
175
+ const root = repoRoot(ctx.cwd) ?? ctx.cwd;
176
+ const envPath = resolve(root, str(inv.flags["env-file"]) ?? ".env");
177
+ written.env = { path: envPath, result: upsertEnvLine(envPath, KEY_ENV, keyValue), gitignored: ensureGitignore(root, envPath.slice(root.length + 1) || ".env") };
178
+ }
179
+ if (inv.flags.vault && keyValue) {
180
+ const r = await ctx.callRaw("vault_put_env", { project_id: String(proj.id), entries: [{ key: KEY_ENV, value: keyValue }], sensitivity: "secret" });
181
+ written.vault = r.ok ? { ok: true } : { ok: false, error: r.message ?? r.error };
182
+ }
183
+ if (ctx.flags.json) {
184
+ ctx.out.jsonOut({ ...data, written });
185
+ return EXIT.OK;
186
+ }
187
+ const out = ctx.out;
188
+ out.log(`${out.green("✓")} ${out.bold(String(data.repo ?? repo))} → ${out.bold(String(proj.name ?? ""))}${data.project_created ? out.dim(" (project created)") : data.repo_linked ? out.dim(" (repository linked)") : out.dim(" (already linked)")}`);
189
+ out.log("");
190
+ out.log(` ${out.dim("ingest key ")} ${keyValue}${data.key_created ? out.dim(" (new)") : ""}`);
191
+ out.log(` ${out.dim("endpoint ")} ${String(data.ingest_url ?? INGEST_URL)}`);
192
+ out.log(` ${out.dim("opens ")} a task per new error/crash/bug report${key.auto_github_issue ? " + a GitHub issue" : ""}${key.environment ? ` · default environment ${String(key.environment)}` : ""}`);
193
+ if (written.config)
194
+ out.log(` ${out.dim("config ")} ${String(written.config)}`);
195
+ if (written.env) {
196
+ const e = written.env;
197
+ out.log(` ${out.dim(".env ")} ${KEY_ENV} ${String(e.result)} in ${String(e.path)}${e.gitignored ? out.dim(" (added to .gitignore)") : ""}`);
198
+ }
199
+ if (written.vault)
200
+ out.log(` ${out.dim("vault ")} ${written.vault.ok ? `${KEY_ENV} stored as a secret` : out.red(`not stored: ${String(written.vault.error)}`)}`);
201
+ out.log("");
202
+ out.log(out.bold("Next"));
203
+ if (!written.env)
204
+ out.log(` 1. Put the key where the app runs: ${KEY_ENV}=${keyValue} ${out.dim("(or re-run with --env)")}`);
205
+ out.log(` ${written.env ? "1" : "2"}. Send errors, crashes and bug reports to the endpoint — ${out.dim("scribeclock connect --snippets")} prints copy-paste code for browser, Node, React Native, Python and curl.`);
206
+ out.log(` ${written.env ? "2" : "3"}. Check the wiring: ${out.cyan("scribeclock ingest test")} then ${out.cyan("scribeclock issues")} / ${out.cyan("scribeclock health")}`);
207
+ out.log(` ${out.dim(`Docs: ${INGEST_DOCS_URL}`)}`);
208
+ if (inv.flags.snippets) {
209
+ for (const s of ingestSnippets(keyValue)) {
210
+ out.log("");
211
+ out.log(out.bold(`── ${s.label}`));
212
+ out.log(s.code);
213
+ }
214
+ }
215
+ return EXIT.OK;
216
+ },
217
+ });
218
+ // ───────────────────────────── issues ─────────────────────────────
219
+ function levelPaint(ctx, level) {
220
+ const l = String(level ?? "");
221
+ if (l === "fatal")
222
+ return ctx.out.red(ctx.out.bold("fatal"));
223
+ if (l === "error")
224
+ return ctx.out.red("error");
225
+ if (l === "warning")
226
+ return ctx.out.yellow("warning");
227
+ return ctx.out.dim(l);
228
+ }
229
+ function issueStatusPaint(ctx, status) {
230
+ const s = String(status ?? "");
231
+ if (s === "open")
232
+ return ctx.out.yellow("open");
233
+ if (s === "resolved")
234
+ return ctx.out.green("resolved");
235
+ if (s === "ignored")
236
+ return ctx.out.dim("ignored");
237
+ return s;
238
+ }
239
+ function issueTargetArgs(ctx, inv) {
240
+ const project = projectArg(ctx, inv);
241
+ if (project)
242
+ return { project_id: project };
243
+ const repo = repoOf(ctx, inv);
244
+ if (repo)
245
+ return { repo };
246
+ throw usageError("No project or repository.", "Run `scribeclock connect` here, or pass --project <id|name> / --repo owner/repo.");
247
+ }
248
+ export const issuesList = cmd({
249
+ path: "issues",
250
+ group: "Bug reports",
251
+ summary: "Open issues (grouped bug / crash / error reports) for this repository or project, with occurrences and the task each opened.",
252
+ tool: "list_issues",
253
+ positionals: [{ name: "query", description: "Free text over title / location, or a short id." }],
254
+ flags: [
255
+ PROJECT_FLAG,
256
+ REPO_FLAG,
257
+ { name: "status", alias: "s", type: "string", choices: ["open", "resolved", "ignored", "all"], description: "Default open." },
258
+ { name: "level", alias: "l", type: "string", choices: ["fatal", "error", "warning", "info"], description: "Only this level." },
259
+ { name: "kind", alias: "k", type: "string", choices: ["error", "bug_report", "message"], description: "Only this kind." },
260
+ { name: "environment", alias: "e", type: "string", description: "Only issues seen in this environment." },
261
+ { name: "release", type: "string", description: "Only issues seen in this release." },
262
+ { name: "since", type: "string", placeholder: "<date>", description: "Only issues seen since (YYYY-MM-DD or ISO)." },
263
+ { name: "order", type: "string", choices: ["last_seen", "first_seen", "count", "users", "level"], description: "Sort (default last_seen)." },
264
+ { name: "limit", alias: "n", type: "number", description: "Max issues (default 50)." },
265
+ { name: "ids", type: "boolean", description: "Print short ids only." },
266
+ ],
267
+ examples: ["scribeclock issues", "scribeclock issues --level fatal --since 2026-10-01", "scribeclock issues -s all 'checkout'", "scribeclock issues --order count --json | jq '.issues[0]'"],
268
+ async run(ctx, inv) {
269
+ const data = await ctx.call("list_issues", {
270
+ ...issueTargetArgs(ctx, inv),
271
+ status: str(inv.flags.status),
272
+ level: str(inv.flags.level),
273
+ kind: str(inv.flags.kind),
274
+ environment: str(inv.flags.environment),
275
+ release: str(inv.flags.release),
276
+ since: str(inv.flags.since),
277
+ query: inv.positionals.join(" ") || undefined,
278
+ order_by: str(inv.flags.order),
279
+ limit: num(inv.flags.limit),
280
+ });
281
+ const issues = (data.issues ?? []);
282
+ if (inv.flags.ids) {
283
+ for (const i of issues)
284
+ ctx.out.log(String(i.short_id ?? i.id));
285
+ return EXIT.OK;
286
+ }
287
+ return emit(ctx, data, () => {
288
+ const summary = (data.summary ?? {});
289
+ if (!issues.length) {
290
+ ctx.out.log(ctx.out.dim(`No ${String(data.status ?? "open")} issues.`));
291
+ if (data.hint)
292
+ ctx.out.info(ctx.out.dim(String(data.hint)));
293
+ return;
294
+ }
295
+ ctx.out.table(["id", "level", "title", "where", "count", "users", "last seen", "status", "task"], issues.map((i) => [i.short_id, levelPaint(ctx, i.level), i.title, i.culprit ?? "", i.event_count, i.user_count, relTime(String(i.last_seen_at ?? ""), ctx.now()), issueStatusPaint(ctx, i.status), i.task?.key ?? ""]), { maxWidth: 48 });
296
+ ctx.out.info(ctx.out.dim(`${String(data.total ?? issues.length)} issue${Number(data.total ?? issues.length) === 1 ? "" : "s"} · ${String(summary.open ?? 0)} open · ${String(summary.new_24h ?? 0)} new in 24 h · ${String(summary.events_24h ?? 0)} events in 24 h${Number(summary.regressed_7d ?? 0) ? ` · ${String(summary.regressed_7d)} regressed this week` : ""}`));
297
+ });
298
+ },
299
+ });
300
+ export const issuesShow = cmd({
301
+ path: "issues show",
302
+ group: "Bug reports",
303
+ summary: "One issue in full: stack trace, breadcrumbs, trace logs, context, users, releases, the linked task.",
304
+ tool: "get_issue",
305
+ positionals: [{ name: "issue", required: true, description: "Issue id (short or full) or a task key (#123)." }],
306
+ flags: [
307
+ { name: "events", alias: "n", type: "number", description: "Recent events to list (default 5)." },
308
+ { name: "full", type: "boolean", description: "Print the whole stack trace and all log lines." },
309
+ ],
310
+ examples: ["scribeclock issues show 3f2a9c1b", "scribeclock issues show '#731'", "scribeclock issues show 3f2a9c1b --json | jq .sample_event.logs"],
311
+ async run(ctx, inv) {
312
+ const ref = requirePositional(inv, 0, "An issue id", "scribeclock issues show 3f2a9c1b");
313
+ const data = await ctx.call("get_issue", { issue_id: /^#?\d+$/.test(ref) ? taskKey(ctx, ref) : ref, event_limit: num(inv.flags.events) });
314
+ return emit(ctx, data, () => {
315
+ const i = (data.issue ?? {});
316
+ const out = ctx.out;
317
+ out.log(`${out.bold(String(i.short_id ?? ""))} ${out.bold(String(i.title ?? ""))}`);
318
+ const task = i.task;
319
+ out.kv([
320
+ ["status", issueStatusPaint(ctx, i.status)],
321
+ ["level", levelPaint(ctx, i.level)],
322
+ ["kind", i.kind],
323
+ ["where", i.culprit],
324
+ ["project", i.project?.name],
325
+ ["repo", i.repo],
326
+ ["platform", i.platform],
327
+ ["occurrences", `${String(i.event_count)} (${String(i.user_count)} users) · ${String(i.events_24h ?? 0)} in 24 h · ${String(i.events_7d ?? 0)} in 7 d`],
328
+ ["first seen", `${fmtDate(String(i.first_seen_at ?? ""), { time: true })} (${relTime(String(i.first_seen_at ?? ""), ctx.now())})`],
329
+ ["last seen", relTime(String(i.last_seen_at ?? ""), ctx.now())],
330
+ ["environments", i.environments?.join(", ")],
331
+ ["releases", i.releases?.join(", ")],
332
+ ["resolved", i.resolved_at ? `${fmtDate(String(i.resolved_at), { time: true })}${i.resolved_in_release ? ` in ${String(i.resolved_in_release)}` : ""}${i.resolved_by ? ` by ${personName(i.resolved_by)}` : ""}` : ""],
333
+ ["regressed", i.regressed_at ? relTime(String(i.regressed_at), ctx.now()) : ""],
334
+ ["task", task ? `${String(task.key ?? "")} ${String(task.title ?? "")} (${String(task.status ?? "")}${task.assignee ? `, ${personName(task.assignee)}` : ""})` : out.dim("none — scribeclock issues task <id>")],
335
+ ["github", i.github_issue_url],
336
+ ["url", i.url],
337
+ ]);
338
+ const sample = (data.sample_event ?? (data.events ?? [])[0]);
339
+ if (sample) {
340
+ out.log("");
341
+ out.log(out.bold("Latest event") + out.dim(` · ${fmtDate(String(sample.received_at ?? ""), { time: true })}${sample.environment ? ` · ${String(sample.environment)}` : ""}${sample.release ? ` · ${String(sample.release)}` : ""}${sample.url ? ` · ${String(sample.url)}` : ""}`));
342
+ if (sample.user)
343
+ out.log(` ${out.dim("user")} ${JSON.stringify(sample.user)}`);
344
+ if (sample.reporter)
345
+ out.log(` ${out.dim("reporter")} ${personName(sample.reporter)}`);
346
+ const stack = typeof sample.stack === "string" ? sample.stack : typeof sample.stack_preview === "string" ? sample.stack_preview : "";
347
+ if (stack) {
348
+ out.log("");
349
+ out.log(out.bold("Stack trace"));
350
+ const lines = stack.split("\n");
351
+ for (const l of inv.flags.full ? lines : lines.slice(0, 25))
352
+ out.log(` ${l}`);
353
+ if (!inv.flags.full && lines.length > 25)
354
+ out.log(out.dim(` … ${lines.length - 25} more lines (--full)`));
355
+ }
356
+ const crumbs = Array.isArray(sample.breadcrumbs) ? sample.breadcrumbs : [];
357
+ if (crumbs.length) {
358
+ out.log("");
359
+ out.log(out.bold(`Breadcrumbs (${crumbs.length})`));
360
+ for (const b of crumbs.slice(-15))
361
+ out.log(` ${b.timestamp ? out.dim(String(b.timestamp).slice(11, 19) + " ") : ""}${b.category ? out.cyan(String(b.category)) + " " : ""}${String(b.message ?? "")}${b.data ? out.dim(" " + JSON.stringify(b.data).slice(0, 120)) : ""}`);
362
+ }
363
+ const logs = Array.isArray(sample.logs) ? sample.logs : [];
364
+ if (logs.length) {
365
+ out.log("");
366
+ out.log(out.bold(`Trace logs (${logs.length})`));
367
+ for (const l of inv.flags.full ? logs : logs.slice(-30))
368
+ out.log(` ${l.timestamp ? out.dim(String(l.timestamp).slice(11, 19) + " ") : ""}${l.level ? `[${String(l.level)}] ` : ""}${String(l.message ?? "")}`);
369
+ if (!inv.flags.full && logs.length > 30)
370
+ out.log(out.dim(` … ${logs.length - 30} earlier lines (--full)`));
371
+ }
372
+ if (sample.context) {
373
+ out.log("");
374
+ out.log(out.bold("Context"));
375
+ out.log(` ${JSON.stringify(sample.context, null, 2).split("\n").join("\n ")}`);
376
+ }
377
+ if (sample.tags)
378
+ out.log(` ${out.dim("tags")} ${JSON.stringify(sample.tags)}`);
379
+ const att = Array.isArray(sample.attachments) ? sample.attachments : [];
380
+ if (att.length) {
381
+ out.log("");
382
+ out.log(out.bold("Attachments"));
383
+ for (const a of att)
384
+ out.log(` ${String(a.name ?? "")} ${String(a.url ?? "")}`);
385
+ }
386
+ }
387
+ const events = (data.events ?? []);
388
+ if (events.length > 1) {
389
+ out.log("");
390
+ out.log(out.bold(`Recent events (${events.length})`));
391
+ out.table(["when", "env", "release", "user", "message"], events.map((e) => [relTime(String(e.received_at ?? ""), ctx.now()), e.environment ?? "", e.release ?? "", e.user?.email ?? e.user?.id ?? "", e.message]), { maxWidth: 60, indent: 2 });
392
+ }
393
+ const byRelease = (data.by_release ?? {});
394
+ if (Object.keys(byRelease).length > 1)
395
+ out.log(out.dim(` by release: ${Object.entries(byRelease).map(([k, v]) => `${k || "(none)"} ${String(v)}`).join(" · ")}`));
396
+ });
397
+ },
398
+ });
399
+ async function issueStatusCommand(ctx, inv, status) {
400
+ const ref = requirePositional(inv, 0, "An issue id", `scribeclock issues ${status === "open" ? "reopen" : status === "resolved" ? "resolve" : "ignore"} 3f2a9c1b`);
401
+ const args = { issue_id: /^#?\d+$/.test(ref) ? taskKey(ctx, ref) : ref, status };
402
+ if (status === "resolved") {
403
+ const release = str(inv.flags.release) ?? (inv.flags.commit ? lastCommit(ctx.cwd)?.sha.slice(0, 12) : undefined);
404
+ if (release)
405
+ args.release = release;
406
+ if (inv.flags["keep-task"])
407
+ args.complete_task = false;
408
+ }
409
+ if (status === "open" && inv.flags["keep-task"])
410
+ args.reopen_task = false;
411
+ const data = await ctx.call("update_issue", args);
412
+ return emit(ctx, data, () => {
413
+ const i = (data.issue ?? {});
414
+ const changed = (data.changed ?? []);
415
+ ctx.out.log(`${ctx.out.green("✓")} ${String(i.short_id ?? "")} ${issueStatusPaint(ctx, i.status)}${i.resolved_in_release ? ctx.out.dim(` in ${String(i.resolved_in_release)}`) : ""}${changed.includes("task_completed") ? ctx.out.dim(" · task completed") : changed.includes("task_reopened") ? ctx.out.dim(" · task reopened") : ""} — ${String(i.title ?? "")}`);
416
+ if (status === "resolved" && !i.resolved_in_release)
417
+ ctx.out.info(ctx.out.dim("Tip: pass --release <version> (or --commit) so a later occurrence in another release counts as a regression."));
418
+ });
419
+ }
420
+ export const issuesResolve = cmd({
421
+ path: "issues resolve",
422
+ group: "Bug reports",
423
+ summary: "Mark an issue fixed (completes its task); with --release, a later occurrence elsewhere reopens it as a regression.",
424
+ tool: "update_issue",
425
+ positionals: [{ name: "issue", required: true, description: "Issue id or task key." }],
426
+ flags: [
427
+ { name: "release", type: "string", description: "Version / build that fixes it." },
428
+ { name: "commit", type: "boolean", description: "Use the last commit sha as the release." },
429
+ { name: "keep-task", type: "boolean", description: "Leave the linked task as it is." },
430
+ ],
431
+ examples: ["scribeclock issues resolve 3f2a9c1b --release 1.4.3", "scribeclock issues resolve '#731' --commit"],
432
+ run: (ctx, inv) => issueStatusCommand(ctx, inv, "resolved"),
433
+ });
434
+ export const issuesIgnore = cmd({
435
+ path: "issues ignore",
436
+ group: "Bug reports",
437
+ summary: "Mute an issue: it keeps counting but stays out of the open list.",
438
+ tool: "update_issue",
439
+ positionals: [{ name: "issue", required: true, description: "Issue id or task key." }],
440
+ run: (ctx, inv) => issueStatusCommand(ctx, inv, "ignored"),
441
+ });
442
+ export const issuesReopen = cmd({
443
+ path: "issues reopen",
444
+ group: "Bug reports",
445
+ summary: "Reopen a resolved or ignored issue (and its completed task).",
446
+ tool: "update_issue",
447
+ positionals: [{ name: "issue", required: true, description: "Issue id or task key." }],
448
+ flags: [{ name: "keep-task", type: "boolean", description: "Leave the linked task as it is." }],
449
+ run: (ctx, inv) => issueStatusCommand(ctx, inv, "open"),
450
+ });
451
+ export const issuesTask = cmd({
452
+ path: "issues task",
453
+ group: "Bug reports",
454
+ summary: "Open a task for an issue that has none (or link it to an existing task with --task).",
455
+ tool: "update_issue",
456
+ positionals: [{ name: "issue", required: true, description: "Issue id." }],
457
+ flags: [
458
+ { name: "task", alias: "t", type: "string", description: "Link this existing task instead of creating one." },
459
+ { name: "github", type: "boolean", description: "Also open a GitHub issue." },
460
+ ],
461
+ examples: ["scribeclock issues task 3f2a9c1b", "scribeclock issues task 3f2a9c1b --task #731"],
462
+ async run(ctx, inv) {
463
+ const ref = requirePositional(inv, 0, "An issue id", "scribeclock issues task 3f2a9c1b");
464
+ const args = { issue_id: ref };
465
+ if (str(inv.flags.task))
466
+ args.task_id = taskKey(ctx, String(inv.flags.task));
467
+ else {
468
+ args.create_task = true;
469
+ if (inv.flags.github)
470
+ args.github_issue = true;
471
+ }
472
+ const data = await ctx.call("update_issue", args);
473
+ return emit(ctx, data, () => {
474
+ const i = (data.issue ?? {});
475
+ const task = i.task ?? null;
476
+ const key = String(data.task_key ?? task?.key ?? "");
477
+ ctx.out.log(`${ctx.out.green("✓")} ${String(i.short_id ?? ref)} → task ${ctx.out.bold(key)}${data.task_created ? "" : ctx.out.dim(" (linked)")}${data.github_issue_url ? ctx.out.dim(` · ${String(data.github_issue_url)}`) : ""}`);
478
+ if (task?.url)
479
+ ctx.out.log(` ${String(task.url)}`);
480
+ });
481
+ },
482
+ });
483
+ // ───────────────────────────── bug ─────────────────────────────
484
+ function readLogFile(ctx, path, tail) {
485
+ const p = resolve(ctx.cwd, path);
486
+ if (!existsSync(p))
487
+ throw usageError(`Log file not found: ${p}`);
488
+ const lines = readFileSync(p, "utf8").split(/\r?\n/).filter((l) => l.trim());
489
+ return lines.slice(-tail);
490
+ }
491
+ export const bugCommand = cmd({
492
+ path: "bug",
493
+ group: "Bug reports",
494
+ summary: 'File a bug report on this project: scribeclock bug "title" -d "steps…" [--log app.log] — it opens a task (and a GitHub issue when enabled).',
495
+ description: "Sends a report through the same pipeline as the app's own crash reports: grouped into an issue, a task opened on the project, the team emailed. Attach the tail of a log file (--log), a stack trace (--stack file or -), the current URL / screen, environment and release (defaults to the last commit with --commit). Reads the project from .scribeclock.json or the git remote.",
496
+ tool: "report_bug",
497
+ positionals: [{ name: "title", required: true, description: "One line: what went wrong." }],
498
+ flags: [
499
+ PROJECT_FLAG,
500
+ REPO_FLAG,
501
+ { name: "description", alias: "d", type: "string", description: "Steps to reproduce, expected vs actual (or @file / @- for stdin)." },
502
+ { name: "type", type: "string", choices: ["bug_report", "error", "crash", "message"], description: "Default bug_report (error when --stack is given)." },
503
+ { name: "level", alias: "l", type: "string", choices: ["fatal", "error", "warning", "info"], description: "Severity (default error; fatal for crashes)." },
504
+ { name: "log", type: "string", placeholder: "<file>", description: "Attach the last lines of this log file as trace logs." },
505
+ { name: "tail", type: "number", description: "How many log lines (default 200)." },
506
+ { name: "stack", type: "string", placeholder: "<file|->", description: "Stack trace from a file (- for stdin)." },
507
+ { name: "url", alias: "u", type: "string", description: "Page URL where it happened." },
508
+ { name: "route", type: "string", description: "Screen / route where it happened." },
509
+ { name: "environment", alias: "e", type: "string", description: "production, staging, development…" },
510
+ { name: "release", type: "string", description: "Version / build / commit." },
511
+ { name: "commit", type: "boolean", description: "Use the last commit sha as the release." },
512
+ { name: "platform", type: "string", description: "web, ios, android, node…" },
513
+ { name: "tag", type: "array", placeholder: "<k=v>", description: "Tags, repeatable or comma-separated." },
514
+ { name: "attach", alias: "a", type: "array", placeholder: "<url>", description: "Public URLs of screenshots / files." },
515
+ { name: "fingerprint", type: "string", description: "Custom grouping key." },
516
+ { name: "user", type: "string", description: "Affected end user: id or email." },
517
+ ],
518
+ examples: ['scribeclock bug "Checkout button does nothing" -d "Click Pay → nothing happens" --log logs/app.log', 'scribeclock bug "500 on /api/orders" --stack error.txt --commit -e staging', 'scribeclock bug "Crash on launch" --type crash --platform ios --tag device=iphone15'],
519
+ async run(ctx, inv) {
520
+ const title = requirePositional(inv, 0, "A title", 'scribeclock bug "Checkout button does nothing"');
521
+ let description = str(inv.flags.description);
522
+ if (description?.startsWith("@"))
523
+ description = description === "@-" ? readFileSync(0, "utf8") : readFileSync(resolve(ctx.cwd, description.slice(1)), "utf8");
524
+ let stack;
525
+ if (str(inv.flags.stack))
526
+ stack = inv.flags.stack === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(ctx.cwd, String(inv.flags.stack)), "utf8");
527
+ const logs = str(inv.flags.log) ? readLogFile(ctx, String(inv.flags.log), num(inv.flags.tail) ?? 200) : undefined;
528
+ const tags = {};
529
+ for (const t of list(inv.flags.tag) ?? []) {
530
+ const [k, ...rest] = t.split("=");
531
+ if (k && rest.length)
532
+ tags[k.trim()] = rest.join("=").trim();
533
+ }
534
+ const branch = currentBranch(ctx.cwd);
535
+ if (branch)
536
+ tags.branch = branch;
537
+ const args = {
538
+ ...issueTargetArgs(ctx, inv),
539
+ title,
540
+ description,
541
+ type: str(inv.flags.type),
542
+ level: str(inv.flags.level),
543
+ stack,
544
+ logs,
545
+ url: str(inv.flags.url),
546
+ route: str(inv.flags.route),
547
+ environment: str(inv.flags.environment),
548
+ release: str(inv.flags.release) ?? (inv.flags.commit ? lastCommit(ctx.cwd)?.sha.slice(0, 12) : undefined),
549
+ platform: str(inv.flags.platform),
550
+ tags: Object.keys(tags).length ? tags : undefined,
551
+ attachments: list(inv.flags.attach),
552
+ fingerprint: str(inv.flags.fingerprint),
553
+ user: str(inv.flags.user) ? (String(inv.flags.user).includes("@") ? { email: inv.flags.user } : { id: inv.flags.user }) : undefined,
554
+ source: "cli",
555
+ };
556
+ return guardedEmit(ctx, "report_bug", args, (data) => {
557
+ const i = (data.issue ?? {});
558
+ ctx.out.log(`${ctx.out.green("✓")} Reported ${ctx.out.bold(String(i.short_id ?? data.issue_id ?? ""))}${data.new_issue ? "" : ctx.out.dim(" (joins an existing issue)")}${data.task_key ? ` → task ${ctx.out.bold(String(data.task_key))}${data.task_created ? "" : ctx.out.dim(" (existing)")}` : ""}`);
559
+ if (data.task_url)
560
+ ctx.out.log(` ${String(data.task_url)}`);
561
+ if (data.github_issue_url)
562
+ ctx.out.log(` ${String(data.github_issue_url)}`);
563
+ if (!data.task_id && data.hint)
564
+ ctx.out.info(ctx.out.dim(String(data.hint)));
565
+ });
566
+ },
567
+ });
568
+ // ───────────────────────────── ingest (keys, test) ─────────────────────────────
569
+ export const ingestKeys = cmd({
570
+ path: "ingest keys",
571
+ group: "Bug reports",
572
+ summary: "The project's ingest keys: label, prefix, repository, auto-task / GitHub settings, usage.",
573
+ tool: "list_ingest_keys",
574
+ flags: [PROJECT_FLAG, { name: "reveal", type: "boolean", description: "Print the full keys (managers only)." }],
575
+ async run(ctx, inv) {
576
+ const project = projectArg(ctx, inv, { required: true });
577
+ const data = await ctx.call("list_ingest_keys", { project_id: project, reveal: inv.flags.reveal ? true : undefined });
578
+ return emit(ctx, data, () => {
579
+ const keys = (data.keys ?? []);
580
+ if (!keys.length) {
581
+ ctx.out.log(ctx.out.dim("No ingest keys yet. `scribeclock connect` creates one."));
582
+ return;
583
+ }
584
+ ctx.out.table(["id", "label", "key", "repo", "env", "tasks", "github", "events", "last used", "enabled"], keys.map((k) => [String(k.id).slice(0, 8), k.label, k.key ?? `${String(k.key_prefix)}…`, k.repo ?? "", k.environment ?? "", k.auto_task ? "yes" : "no", k.auto_github_issue ? "yes" : "no", k.event_count, k.last_used_at ? relTime(String(k.last_used_at), ctx.now()) : "never", k.enabled ? "yes" : ctx.out.red("no")]));
585
+ });
586
+ },
587
+ });
588
+ export const ingestRevoke = cmd({
589
+ path: "ingest revoke",
590
+ group: "Bug reports",
591
+ summary: "Revoke an ingest key (apps still using it get 401).",
592
+ tool: "revoke_ingest_key",
593
+ positionals: [{ name: "key", required: true, description: "Key id (from `ingest keys`; a prefix of the uuid is enough)." }],
594
+ flags: [PROJECT_FLAG],
595
+ async run(ctx, inv) {
596
+ const ref = requirePositional(inv, 0, "A key id", "scribeclock ingest revoke 1b2c3d4e");
597
+ let keyId = ref;
598
+ if (!/^[0-9a-f-]{36}$/i.test(ref)) {
599
+ const project = projectArg(ctx, inv, { required: true });
600
+ const data = await ctx.call("list_ingest_keys", { project_id: project });
601
+ const match = (data.keys ?? []).filter((k) => String(k.id).startsWith(ref.toLowerCase()));
602
+ if (match.length !== 1)
603
+ throw usageError(match.length ? `${match.length} keys start with ${ref}.` : `No key starts with ${ref}.`, "scribeclock ingest keys shows the ids.");
604
+ keyId = String(match[0].id);
605
+ }
606
+ return guardedEmit(ctx, "revoke_ingest_key", { key_id: keyId }, (data) => {
607
+ const k = (data.ingest_key ?? {});
608
+ ctx.out.log(`${ctx.out.green("✓")} Revoked ${String(k.label ?? keyId)} (${String(k.key_prefix ?? "")}…).`);
609
+ });
610
+ },
611
+ });
612
+ export const ingestTest = cmd({
613
+ path: "ingest test",
614
+ group: "Bug reports",
615
+ summary: "Send a test event to the ingest endpoint with the key from .env / SCRIBECLOCK_INGEST_KEY and show the issue and task it produced.",
616
+ description: "Posts a small error event straight to https://scribeclock.com/ingest the way an app would (no MCP login needed), so you can check the key, the endpoint and the task automation end to end. Resolve the test issue afterwards: scribeclock issues resolve <id>.",
617
+ tool: ["get_issue"],
618
+ flags: [
619
+ { name: "key", alias: "k", type: "string", description: `Ingest key (default: ${KEY_ENV} from the environment or .env).` },
620
+ { name: "url", type: "string", description: `Endpoint (default ${INGEST_URL}, or .scribeclock.json → ingest.url).` },
621
+ { name: "type", type: "string", choices: ["error", "crash", "bug_report", "message", "event"], description: "Event type (default error)." },
622
+ { name: "message", alias: "m", type: "string", description: "Message (default: a ScribeClock test error)." },
623
+ { name: "environment", alias: "e", type: "string", description: "Environment (default development)." },
624
+ ],
625
+ examples: ["scribeclock ingest test", "scribeclock ingest test --type bug_report -m 'Test report from the CLI'", "scribeclock ingest test --key sc_ing_… --json"],
626
+ async run(ctx, inv) {
627
+ const key = ingestKeyFor(ctx, str(inv.flags.key));
628
+ if (!key)
629
+ throw new CliError(`No ingest key: set ${KEY_ENV}, put it in .env, or pass --key.`, { id: "no_ingest_key", hint: "scribeclock connect --env writes it for you." });
630
+ const url = str(inv.flags.url) ?? (ctx.repo.config.ingest?.url ?? INGEST_URL);
631
+ const type = str(inv.flags.type) ?? "error";
632
+ const message = str(inv.flags.message) ?? "ScribeClock ingest test: this error was sent by `scribeclock ingest test`";
633
+ const commit = lastCommit(ctx.cwd);
634
+ const event = {
635
+ type,
636
+ message,
637
+ ...(type === "error" || type === "crash" ? { exception: { type: "TestError", value: message, stacktrace: `TestError: ${message}\n at ingestTest (scribeclock-cli/src/commands/ingest.ts:1:1)\n at main (scribeclock-cli/src/index.ts:1:1)` } } : {}),
638
+ ...(type === "bug_report" ? { title: message, description: "Sent by `scribeclock ingest test` to check that bug reports reach ScribeClock. Safe to resolve." } : {}),
639
+ ...(type === "event" ? { name: "scribeclock_ingest_test" } : {}),
640
+ environment: str(inv.flags.environment) ?? "development",
641
+ release: commit ? commit.sha.slice(0, 12) : undefined,
642
+ platform: "cli",
643
+ tags: { source: "scribeclock-cli", test: "true", ...(currentBranch(ctx.cwd) ? { branch: currentBranch(ctx.cwd) } : {}) },
644
+ breadcrumbs: [{ timestamp: ctx.now().toISOString(), category: "cli", message: "scribeclock ingest test started" }],
645
+ logs: [`${ctx.now().toISOString()} info test event from scribeclock ${ctx.version}`],
646
+ event_id: `cli-test-${ctx.now().getTime()}`,
647
+ };
648
+ if (ctx.flags.dryRun)
649
+ return emit(ctx, { ok: true, dry_run: true, url, event }, () => ctx.out.log(`${ctx.out.dim("dry run →")} POST ${url} ${JSON.stringify(event)}`));
650
+ const started = Date.now();
651
+ let res;
652
+ try {
653
+ res = await ctx.fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}`, "User-Agent": `scribeclock-cli/${ctx.version}` }, body: JSON.stringify(event) });
654
+ }
655
+ catch (err) {
656
+ throw new CliError(`Could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, { id: "ingest_unreachable" });
657
+ }
658
+ const body = (await res.json().catch(() => ({})));
659
+ const ms = Date.now() - started;
660
+ if (!res.ok || body.ok === false) {
661
+ throw new CliError(`Ingest answered ${res.status}: ${String(body.message ?? body.error ?? "unknown error")}`, { id: String(body.error ?? "ingest_failed"), hint: res.status === 401 ? "The key is unknown or revoked. `scribeclock connect` issues a new one." : undefined, details: body });
662
+ }
663
+ const first = (body.results ?? [])[0] ?? {};
664
+ return emit(ctx, { ...body, url, ms }, () => {
665
+ ctx.out.log(`${ctx.out.green("✓")} ${url} accepted the ${type} event in ${ms} ms.`);
666
+ if (type === "event") {
667
+ ctx.out.log(` counted as analytics event ${ctx.out.bold("scribeclock_ingest_test")} — see it in ${ctx.out.cyan("scribeclock health")}.`);
668
+ return;
669
+ }
670
+ ctx.out.log(` issue ${ctx.out.bold(String(first.issue_short_id ?? body.issue_id ?? ""))}${first.new_issue ? ctx.out.dim(" (new)") : first.duplicate ? ctx.out.dim(" (duplicate event)") : ctx.out.dim(" (existing)")}${first.task_key ? ` → task ${ctx.out.bold(String(first.task_key))}${first.task_created ? ctx.out.dim(" opened") : ""}` : first.task_id ? ` → task ${String(first.task_id).slice(0, 8)}` : ctx.out.dim(" (no task: level not in the key's task levels)")}`);
671
+ if (first.task_url)
672
+ ctx.out.log(` ${String(first.task_url)}`);
673
+ if (first.github_issue_url)
674
+ ctx.out.log(` ${String(first.github_issue_url)}`);
675
+ ctx.out.info(ctx.out.dim(`Clean up: scribeclock issues resolve ${String(first.issue_short_id ?? "")}`));
676
+ });
677
+ },
678
+ });
679
+ export const ingestSnippetsCommand = cmd({
680
+ path: "ingest snippets",
681
+ group: "Bug reports",
682
+ summary: "Print copy-paste SDK snippets (browser, Node, React Native, Python, curl) for this project's ingest key.",
683
+ tool: ["list_ingest_keys"],
684
+ flags: [{ name: "key", alias: "k", type: "string", description: "Ingest key to embed (default: from the environment / .env, else a placeholder)." }, { name: "lang", type: "string", choices: ["browser", "node", "react-native", "python", "curl"], description: "Only one language." }],
685
+ async run(ctx, inv) {
686
+ const key = ingestKeyFor(ctx, str(inv.flags.key)) ?? "sc_ing_<your-key>";
687
+ const snippets = ingestSnippets(key).filter((s) => !str(inv.flags.lang) || s.id === inv.flags.lang);
688
+ if (ctx.flags.json) {
689
+ ctx.out.jsonOut({ ok: true, endpoint: INGEST_URL, docs: INGEST_DOCS_URL, snippets });
690
+ return EXIT.OK;
691
+ }
692
+ for (const s of snippets) {
693
+ ctx.out.log(ctx.out.bold(`── ${s.label}`));
694
+ ctx.out.log(s.code);
695
+ ctx.out.log("");
696
+ }
697
+ ctx.out.info(ctx.out.dim(`Docs: ${INGEST_DOCS_URL}`));
698
+ return EXIT.OK;
699
+ },
700
+ });
701
+ // ───────────────────────────── health ─────────────────────────────
702
+ export const healthCommand = cmd({
703
+ path: "health",
704
+ group: "Bug reports",
705
+ summary: "How the app is doing: crashes, errors and bug reports per day, users affected, top and new issues, regressions, releases, analytics.",
706
+ tool: "project_health",
707
+ flags: [PROJECT_FLAG, REPO_FLAG, { name: "days", alias: "d", type: "number", description: "Window in days (default 7, max 90)." }],
708
+ examples: ["scribeclock health", "scribeclock health --days 30 --project 'Dekki app'"],
709
+ async run(ctx, inv) {
710
+ const data = await ctx.call("project_health", { ...issueTargetArgs(ctx, inv), days: num(inv.flags.days) });
711
+ return emit(ctx, data, () => {
712
+ const out = ctx.out;
713
+ const p = (data.project ?? {});
714
+ const t = (data.totals ?? {});
715
+ const prev = Number(t.events_previous_window ?? 0);
716
+ const cur = Number(t.events ?? 0);
717
+ const delta = prev === 0 ? (cur ? "new" : "–") : `${cur >= prev ? "+" : ""}${Math.round(((cur - prev) / prev) * 100)}%`;
718
+ out.log(`${out.bold(String(p.name ?? ""))} · last ${String(data.days)} days`);
719
+ out.kv([
720
+ ["events", `${cur} (${delta} vs previous ${String(data.days)} days)`],
721
+ ["crashes", t.crashes],
722
+ ["errors", t.errors],
723
+ ["bug reports", t.bug_reports],
724
+ ["users affected", t.users_affected],
725
+ ["open issues", `${String(t.open_issues ?? 0)} · ${String(t.resolved_in_window ?? 0)} resolved in window`],
726
+ ]);
727
+ const days = (data.by_day ?? []);
728
+ if (days.some((d) => Number(d.events) > 0)) {
729
+ out.log("");
730
+ out.log(out.bold("Per day") + out.dim(" fatal/error/warning/bug"));
731
+ const max = Math.max(1, ...days.map((d) => Number(d.events)));
732
+ for (const d of days) {
733
+ const n = Number(d.events);
734
+ const bar = "█".repeat(Math.round((n / max) * 24));
735
+ out.log(` ${String(d.day)} ${n.toString().padStart(5)} ${out.cyan(bar)} ${out.dim(`${String(d.fatal)}/${String(d.error)}/${String(d.warning)}/${String(d.bug_reports)}`)}`);
736
+ }
737
+ }
738
+ const section = (title, rows) => {
739
+ if (!rows.length)
740
+ return;
741
+ out.log("");
742
+ out.log(out.bold(title));
743
+ out.table(["id", "level", "title", "count", "users", "last seen", "task"], rows.map((i) => [i.short_id, levelPaint(ctx, i.level), i.title, i.event_count, i.user_count, relTime(String(i.last_seen_at ?? ""), ctx.now()), i.task?.key ?? ""]), { maxWidth: 50, indent: 2 });
744
+ };
745
+ section("Top issues", (data.top_issues ?? []));
746
+ section("New issues", (data.new_issues ?? []).filter((i) => !(data.top_issues ?? []).some((x) => x.id === i.id)));
747
+ section("Regressions", (data.regressions ?? []));
748
+ const releases = (data.releases ?? []);
749
+ if (releases.length) {
750
+ out.log("");
751
+ out.log(out.bold("Releases"));
752
+ out.table(["release", "events", "issues", "first seen"], releases.map((r) => [r.release, r.events, r.issues, relTime(String(r.first_seen_at ?? ""), ctx.now())]), { indent: 2 });
753
+ }
754
+ const analytics = (data.analytics ?? []);
755
+ if (analytics.length) {
756
+ out.log("");
757
+ out.log(out.bold("Analytics events"));
758
+ out.table(["event", "count", "environments"], analytics.map((a) => [a.name, a.count, a.environments?.join(", ") ?? ""]), { indent: 2 });
759
+ }
760
+ const keys = (data.ingest_keys ?? []);
761
+ out.log("");
762
+ if (!keys.length)
763
+ out.log(out.yellow("No ingest key yet — run `scribeclock connect`."));
764
+ else
765
+ out.log(out.dim(`Ingest keys: ${keys.map((k) => `${String(k.label)}${k.enabled ? "" : " (revoked)"} · ${String(k.event_count)} events${k.last_used_at ? `, last ${relTime(String(k.last_used_at), ctx.now())}` : ", never used"}`).join(" · ")}`));
766
+ if (data.hint && (!keys.length || cur === 0))
767
+ out.info(out.dim(String(data.hint)));
768
+ });
769
+ },
770
+ });
771
+ export const INGEST_COMMANDS = [connectCommand, issuesList, issuesShow, issuesResolve, issuesIgnore, issuesReopen, issuesTask, bugCommand, ingestKeys, ingestRevoke, ingestTest, ingestSnippetsCommand, healthCommand];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scribeclock",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "ScribeClock from the terminal: tasks, time clock, projects, PRDs, meetings, files, vault and Cloudflare — a thin client over the ScribeClock MCP server.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Haydar Al-Karar LLC, dba Karar Tech",