meksus 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,9 @@ Start a new Claude Code session after installing the hooks. It then appears on t
38
38
  ## Check
39
39
 
40
40
  ```sh
41
- meksus status # who you're signed in as, whether you have access, and this repo's project
41
+ meksus status # who you're signed in as, this repo's project, then three cards:
42
+ # AI assistants, services & spending, problems to fix (--all: every project)
43
+ meksus ack 3f2a # acknowledge a problem (the id's first characters are enough); resolve works the same
42
44
  ```
43
45
 
44
46
  ## What is sent
@@ -56,7 +58,8 @@ The server scrubs anything that looks like a secret.
56
58
  | Command | What it does |
57
59
  |---|---|
58
60
  | `meksus login` / `logout` | Sign in with GitHub, or sign out |
59
- | `meksus status` | Account, access, and this repo's project |
61
+ | `meksus status [--all] [--json]` | Account and this repo's project, then the dashboard's three cards: AI assistants, services & spending, problems to fix |
62
+ | `meksus ack <id>` · `meksus resolve <id>` | Acknowledge or resolve a problem from the terminal; the Discord card updates for everyone |
60
63
  | `meksus project list` | Your organisations, Personal, and their projects |
61
64
  | `meksus project create <name> [--in <parent>] [--use]` | Create a project (owners and admins) |
62
65
  | `meksus project use <owner/project>` | Write this repo's `.meksus` |
package/bin/meksus.mjs CHANGED
@@ -5,6 +5,7 @@ import { login, logout } from "../src/auth.mjs";
5
5
  import { install, runHook, uninstall } from "../src/hooks.mjs";
6
6
  import { apiBase, dir, readConfig } from "../src/config.mjs";
7
7
  import { projectCommand, readProjectFile } from "../src/project.mjs";
8
+ import { incidentCommand, printCards } from "../src/cards.mjs";
8
9
  import { flush } from "../src/spool.mjs";
9
10
  import { taskLinesCommand } from "../src/tasks.mjs";
10
11
  import { VERSION } from "../src/version.mjs";
@@ -14,7 +15,8 @@ const HELP = `M.E.K.S.U.S. CLI ${VERSION}
14
15
 
15
16
  meksus login [--no-browser] Sign in with GitHub (your account, not this machine)
16
17
  meksus logout End this sign-in
17
- meksus status Who you are, and which project this repo reports to
18
+ meksus status [--all] [--json] Who you are, then AI assistants, services & spend, and problems
19
+ meksus ack <id> | meksus resolve <id> Acknowledge or resolve a problem (id from status; a prefix is enough)
18
20
  meksus project list Your organisations, Personal, and their projects
19
21
  meksus project create <name> [--in <parent>] [--slug <slug>] [--use]
20
22
  meksus project use <owner/project> Write this repo's .meksus file (commit it)
@@ -36,6 +38,8 @@ switch (command) {
36
38
  case "login": code = await login({ noBrowser: flag("--no-browser") }); break;
37
39
  case "logout": code = await logout(); break;
38
40
  case "status": code = await status(); break;
41
+ case "ack": case "acknowledge": code = await incidentCommand("acknowledge", rest[0]); break;
42
+ case "resolve": code = await incidentCommand("resolve", rest[0]); break;
39
43
  case "project": case "projects": code = (await requireAccess()) ? await projectCommand(rest) : 1; break;
40
44
  case "hooks":
41
45
  if (rest[0] === "install") code = (await requireAccess()) ? install() : 1;
@@ -64,5 +68,6 @@ async function status() {
64
68
  }
65
69
  console.log(project ? `This repo reports to ${project.name ?? project.id} (${project.file})` : "This repo has no .meksus file: nothing here is reported. `meksus project use <owner/project>` adds one.");
66
70
  console.log(`API ${apiBase(c)}`);
67
- return c?.session ? 0 : 1;
71
+ if (!c?.session) return 1;
72
+ return printCards({ json: flag("--json"), all: flag("--all") });
68
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meksus",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "M.E.K.S.U.S. CLI: see what your AI coding agents (Claude Code) and builds are doing, live, and get Discord alerts when they fail or need you. Status only: code, prompts and output never leave your machine.",
5
5
  "keywords": [
6
6
  "meksus",
package/src/auth.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  // typed, pasted or shown:
5
5
  // 1. ask the ingest Worker where Supabase lives (/v1/cli/config: public values only)
6
6
  // 2. listen on http://127.0.0.1:<random port> for one callback
7
- // 3. open <supabase>/auth/v1/authorize?provider=github&code_challenge=… The browser comes back to
8
- // https://<app>/cli-callback, a static page that forwards ?code= to the local listener. The code is
7
+ // 3. open https://<app>/cli-callback?challenge=…, which forwards to <supabase>/auth/v1/authorize (GitHub,
8
+ // PKCE). The browser comes back to the same static page, which forwards ?code= to the local listener. The code is
9
9
  // useless without this process's verifier, which never leaves it.
10
10
  // 4. exchange code + verifier for a session; keep it in config.json (0600)
11
11
  // Access tokens last an hour; getAccessToken() refreshes them (rotating refresh token) when needed.
@@ -55,10 +55,9 @@ export async function login({ noBrowser = false } = {}) {
55
55
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
56
56
  const port = server.address().port;
57
57
 
58
- const redirect = `${cfg.app_url.replace(/\/$/, "")}/cli-callback?port=${port}&state=${state}`;
59
- const authorize = `${cfg.supabase_url.replace(/\/$/, "")}/auth/v1/authorize?${new URLSearchParams({
60
- provider: "github", redirect_to: redirect, code_challenge: challenge, code_challenge_method: "s256",
61
- })}`;
58
+ // The browser starts at the app's own /cli-callback page, which forwards to GitHub sign-in (through
59
+ // Supabase Auth) and later hands the code back here. Only public values travel: port, state, challenge.
60
+ const authorize = `${cfg.app_url.replace(/\/$/, "")}/cli-callback?${new URLSearchParams({ port: String(port), state, challenge })}`;
62
61
 
63
62
  console.log("\n Sign in to M.E.K.S.U.S. with GitHub\n");
64
63
  console.log(` Open: ${authorize}\n`);
package/src/cards.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // `meksus status` (the dashboard's three cards in the terminal) and `meksus ack|resolve <id>` (CLI-3/4).
2
+ // Reads go straight to the database as the signed-in user, so its access rules decide what's shown,
3
+ // exactly as on the dashboard. Actions use the same incident_action as the dashboard (recorded with
4
+ // source "cli"), then ask the ingest Worker to redraw the Discord card for everyone.
5
+ import { getAccessToken } from "./auth.mjs";
6
+ import { apiBase, readConfig } from "./config.mjs";
7
+ import { readProjectFile } from "./project.mjs";
8
+ import { VERSION } from "./version.mjs";
9
+
10
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
11
+ const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
12
+ const dim = paint("2"), bold = paint("1"), red = paint("31"), orange = paint("33"), green = paint("32"), cyan = paint("36");
13
+ const SEV = { critical: (s) => red(bold(s)), high: red, medium: orange, low: dim };
14
+
15
+ export const shortId = (id) => id.slice(0, 8);
16
+
17
+ async function db(config, token, path, init = {}) {
18
+ const res = await fetch(`${config.supabase_url.replace(/\/$/, "")}/rest/v1/${path}`, {
19
+ ...init,
20
+ headers: { apikey: config.supabase_publishable_key, Authorization: `Bearer ${token}`, "Content-Type": "application/json",
21
+ "User-Agent": `meksus-cli/${VERSION}`, "X-Meksus-Source": "cli", ...(init.headers ?? {}) },
22
+ signal: AbortSignal.timeout(10_000),
23
+ });
24
+ const body = await res.json().catch(() => null);
25
+ return { status: res.status, body };
26
+ }
27
+
28
+ const ago = (iso) => {
29
+ const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
30
+ return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
31
+ };
32
+ const clip = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s ?? "");
33
+ const usd = (v) => (typeof v === "number" ? `$${v.toFixed(2)}` : "—");
34
+
35
+ /** The three cards' data for one project (the repo's .meksus) or every project the user can see. */
36
+ export async function loadOverview(config, token, projectId) {
37
+ const only = projectId ? `&project_id=eq.${projectId}` : "";
38
+ const [projects, incidents, agents, connectors] = await Promise.all([
39
+ db(config, token, `projects?select=id,name${projectId ? `&id=eq.${projectId}` : ""}&order=name`),
40
+ db(config, token, `incidents?select=id,title,severity,status,occurrences,last_seen_at,project_id,muted&status=neq.resolved${only}&order=last_seen_at.desc&limit=100`),
41
+ db(config, token, `agent_sessions?select=id,label,agent,state,task,last_event_at,project_id&ended_at=is.null${only}&order=last_event_at.desc&limit=20`),
42
+ db(config, token, `connectors?select=provider,status,snapshot,project_id${only}`),
43
+ ]);
44
+ for (const r of [projects, incidents, agents, connectors]) if (r.status === 401) return { error: "signed_out" };
45
+ if ([projects, incidents, agents, connectors].some((r) => !Array.isArray(r.body))) return { error: "unavailable" };
46
+ const name = Object.fromEntries(projects.body.map((p) => [p.id, p.name]));
47
+ const now = Date.now();
48
+ const live = agents.body.filter((a) => now - Date.parse(a.last_event_at) < 30 * 60_000);
49
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
50
+ const problems = incidents.body.filter((i) => !i.muted)
51
+ .sort((a, b) => (order[a.severity] - order[b.severity]) || Date.parse(b.last_seen_at) - Date.parse(a.last_seen_at));
52
+ return {
53
+ scope: projectId ? name[projectId] ?? "this project" : `${projects.body.length} project${projects.body.length === 1 ? "" : "s"}`,
54
+ agents: live.map((a) => ({ ...a, project: name[a.project_id], quiet: a.state !== "waiting" && now - Date.parse(a.last_event_at) > 5 * 60_000 })),
55
+ spend: connectors.body.map((c) => ({ provider: c.provider, status: c.status, today_usd: c.snapshot?.usage_daily_usd ?? null, remaining_usd: c.snapshot?.remaining_usd ?? null, project: name[c.project_id] })),
56
+ problems: problems.map((i) => ({ ...i, project: name[i.project_id] })),
57
+ };
58
+ }
59
+
60
+ /** Plain-text cards (no colour when piped or NO_COLOR is set). */
61
+ export function renderCards(o, { width = 78 } = {}) {
62
+ const out = [];
63
+ const head = (title, note) => { out.push(""); out.push(`${bold(title)}${note ? ` ${dim(note)}` : ""}`); out.push(dim("─".repeat(Math.min(width, 78)))); };
64
+ const waiting = o.agents.filter((a) => a.state === "waiting").length;
65
+ head("AI assistants", o.agents.length ? `${o.agents.length} live · ${waiting} waiting for you` : "none running");
66
+ if (!o.agents.length) out.push(dim(" Claude Code and anything under `meksus watch` show up here while they work."));
67
+ for (const a of o.agents.slice(0, 5)) {
68
+ const state = a.state === "waiting" ? orange("waiting for you") : a.quiet ? orange("gone quiet") : a.state === "working" ? green("working") : a.state;
69
+ out.push(` ${clip(a.label ?? a.agent, 32).padEnd(32)} ${state} ${dim(ago(a.last_event_at))}${a.task ? `\n ${dim("→")} ${clip(a.task, width - 6)}` : ""}`);
70
+ }
71
+ head("Service health & spending", o.spend.length ? `${o.spend.length} connected` : null);
72
+ if (!o.spend.length) out.push(dim(" No services connected yet. Connect one on the dashboard (Integrations / Spend)."));
73
+ for (const s of o.spend) {
74
+ const st = s.status === "ok" ? green("ok") : red(s.status);
75
+ out.push(` ${(s.provider === "openrouter" ? "OpenRouter" : s.provider).padEnd(14)} ${st} ${usd(s.today_usd)} today · ${usd(s.remaining_usd)} left${s.project ? dim(` ${s.project}`) : ""}`);
76
+ }
77
+ const needs = o.problems.filter((p) => p.status === "open").length;
78
+ head("Problems to fix", o.problems.length ? `${o.problems.length} open · ${needs} need a person` : null);
79
+ if (!o.problems.length) out.push(` ${green("All steady.")} ${dim("Nothing needs you.")}`);
80
+ for (const p of o.problems.slice(0, 10)) {
81
+ const sev = (SEV[p.severity] ?? dim)(p.severity.padEnd(8));
82
+ out.push(` ${cyan(shortId(p.id))} ${sev} ${clip(p.title, width - 36)}${p.occurrences > 1 ? dim(` ×${p.occurrences}`) : ""}${p.status === "acknowledged" ? dim(" (ack)") : ""}`);
83
+ out.push(` ${dim(`${p.project ?? ""} · last ${ago(p.last_seen_at)}`)}`);
84
+ }
85
+ if (o.problems.length > 10) out.push(dim(` … and ${o.problems.length - 10} more on the dashboard`));
86
+ if (o.problems.length) out.push(dim("\n meksus ack <id> · meksus resolve <id> (the first characters of the id are enough)"));
87
+ return out.join("\n");
88
+ }
89
+
90
+ async function session() {
91
+ const config = readConfig();
92
+ if (!config?.session) return { error: "Not signed in. Run `meksus login`." };
93
+ const token = await getAccessToken(config);
94
+ if (!token) return { error: "Your sign-in expired. Run `meksus login`." };
95
+ return { config, token };
96
+ }
97
+
98
+ /** Appended to `meksus status` (after who you are and which project this repo reports to). */
99
+ export async function printCards({ json = false, all = false } = {}) {
100
+ const s = await session();
101
+ if (s.error) return 1;
102
+ const project = all ? null : readProjectFile();
103
+ const o = await loadOverview(s.config, s.token, project?.id ?? null);
104
+ if (o.error) { console.error(o.error === "signed_out" ? "Your sign-in expired. Run `meksus login`." : "Couldn't load the cards right now. Try again."); return 1; }
105
+ if (json) { console.log(JSON.stringify(o, null, 2)); return 0; }
106
+ console.log(dim(`\nShowing ${o.scope}${project && !all ? " (this repo's .meksus; --all for every project)" : ""}`));
107
+ console.log(renderCards(o, { width: Math.max(60, Math.min(process.stdout.columns ?? 80, 100)) }));
108
+ return 0;
109
+ }
110
+
111
+ /** `meksus ack <id>` / `meksus resolve <id>`: the id prefix is matched among open problems you can see. */
112
+ export async function incidentCommand(action, ref) {
113
+ if (!ref || !/^[0-9a-f-]{4,36}$/i.test(ref)) { console.error(`Usage: meksus ${action === "acknowledge" ? "ack" : "resolve"} <id> (see \`meksus status\`)`); return 2; }
114
+ const s = await session();
115
+ if (s.error) { console.error(s.error); return 1; }
116
+ const list = await db(s.config, s.token, "incidents?select=id,title,status,project_id&status=neq.resolved&order=last_seen_at.desc&limit=200");
117
+ if (!Array.isArray(list.body)) { console.error("Couldn't load your problems right now. Try again."); return 1; }
118
+ const matches = list.body.filter((i) => i.id.startsWith(ref.toLowerCase()));
119
+ if (!matches.length) { console.error(`No open problem starts with ${ref}. \`meksus status --all\` lists them.`); return 1; }
120
+ if (matches.length > 1) { console.error(`${ref} matches ${matches.length} problems; type more of the id:\n${matches.map((m) => ` ${m.id} ${m.title}`).join("\n")}`); return 1; }
121
+ const i = matches[0];
122
+ const r = await db(s.config, s.token, "rpc/incident_action", { method: "POST", body: JSON.stringify({ p_incident: i.id, p_action: action }) });
123
+ if (r.status >= 300 || !r.body?.ok) { console.error(`Not done: ${r.body?.error ?? r.body?.message ?? `HTTP ${r.status}`}.`); return 1; }
124
+ // Redraw the Discord card(s) for everyone, like the dashboard does. Best effort.
125
+ let synced = false;
126
+ try {
127
+ const res = await fetch(`${apiBase(s.config)}/v1/incidents/${i.id}/discord-sync`, { method: "POST", headers: { Authorization: `Bearer ${s.token}`, "User-Agent": `meksus-cli/${VERSION}` }, signal: AbortSignal.timeout(10_000) });
128
+ synced = res.ok && (await res.json().catch(() => null))?.synced !== false;
129
+ } catch { /* the next action redraws it */ }
130
+ console.log(`${green("✔")} ${action === "acknowledge" ? "Acknowledged" : "Resolved"}: ${i.title}${synced ? dim(" (Discord card updated)") : ""}`);
131
+ return 0;
132
+ }
package/src/hooks.mjs CHANGED
@@ -132,9 +132,29 @@ function settingsPath(cwd) {
132
132
  return join(d, ".claude", "settings.local.json");
133
133
  }
134
134
 
135
+ // Reads the settings file, or explains (and changes nothing) when it isn't valid JSON — often two
136
+ // { … } blocks pasted one after the other, which Claude Code can't read either.
137
+ function readSettings(file) {
138
+ if (!existsSync(file)) return {};
139
+ const text = readFileSync(file, "utf8");
140
+ try {
141
+ const value = JSON.parse(text);
142
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
143
+ } catch (error) {
144
+ const at = /line (\d+)/.exec(error.message)?.[1];
145
+ console.error(`✖ ${file} isn't valid JSON${at ? ` (line ${at})` : ""}, so nothing was changed.`);
146
+ if (/after JSON/.test(error.message)) console.error(" It holds more than one { … } block. Merge them into one object, then run this again.");
147
+ console.error(" Claude Code can't read this file either until it's fixed.");
148
+ return null;
149
+ }
150
+ console.error(`✖ ${file} should hold one JSON object, so nothing was changed.`);
151
+ return null;
152
+ }
153
+
135
154
  export function install(cwd = process.cwd()) {
136
155
  const file = settingsPath(cwd);
137
- const settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
156
+ const settings = readSettings(file);
157
+ if (!settings) return 1;
138
158
  settings.hooks ??= {};
139
159
  const command = `node "${CLI}" hook`;
140
160
  for (const event of EVENTS) {
@@ -155,7 +175,8 @@ export function install(cwd = process.cwd()) {
155
175
  export function uninstall(cwd = process.cwd()) {
156
176
  const file = settingsPath(cwd);
157
177
  if (!existsSync(file)) { console.log("No hooks file here."); return 0; }
158
- const settings = JSON.parse(readFileSync(file, "utf8"));
178
+ const settings = readSettings(file);
179
+ if (!settings) return 1;
159
180
  for (const event of Object.keys(settings.hooks ?? {})) {
160
181
  settings.hooks[event] = settings.hooks[event]
161
182
  .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command).includes(MARK)) }))