scribeclock 0.0.0-stage → 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.
@@ -0,0 +1,138 @@
1
+ import { parseFlags } from "./parser.js";
2
+ export const GROUPS = [
3
+ "Getting started",
4
+ "Daily workflow",
5
+ "Time",
6
+ "Tasks",
7
+ "Projects & PRDs",
8
+ "Meetings & docs",
9
+ "Files",
10
+ "Vault & env",
11
+ "Cloudflare",
12
+ "Team",
13
+ "Git",
14
+ "Ops",
15
+ "Automation & agents",
16
+ "Raw tools",
17
+ ];
18
+ export class Registry {
19
+ byPath = new Map();
20
+ add(...commands) {
21
+ for (const c of commands)
22
+ this.byPath.set(c.path, c);
23
+ return this;
24
+ }
25
+ get(path) {
26
+ return this.byPath.get(path);
27
+ }
28
+ list() {
29
+ return [...this.byPath.values()];
30
+ }
31
+ /** Longest prefix of leading non-flag tokens that names a command. */
32
+ resolve(tokens) {
33
+ const words = [];
34
+ for (const t of tokens) {
35
+ if (t.startsWith("-"))
36
+ break;
37
+ words.push(t);
38
+ if (words.length >= 3)
39
+ break;
40
+ }
41
+ for (let n = words.length; n >= 1; n--) {
42
+ const path = words.slice(0, n).join(" ");
43
+ const command = this.byPath.get(path);
44
+ if (command)
45
+ return { command, words: words.slice(0, n), rest: tokens.slice(n) };
46
+ }
47
+ return null;
48
+ }
49
+ /** Commands that start with the given word (for "tasks" alone → list its subcommands). */
50
+ children(word) {
51
+ return this.list().filter((c) => c.path.startsWith(`${word} `) && !c.hidden);
52
+ }
53
+ /** Builds the Invocation for a resolved command. */
54
+ invoke(command, words, rest, passthrough) {
55
+ const { flags, positionals, unknown } = parseFlags(rest, command.flags ?? [], { allowUnknown: command.allowUnknownFlags });
56
+ return { words, positionals, flags, rest, passthrough, unknown };
57
+ }
58
+ }
59
+ const GLOBAL_FLAGS = [
60
+ { name: "json", type: "boolean", description: "Machine-readable JSON output (stable shapes)." },
61
+ { name: "org", type: "string", placeholder: "<id>", description: "Organization id to act in (when you belong to several)." },
62
+ { name: "yes", alias: "y", type: "boolean", description: "Confirm destructive or sensitive actions without prompting." },
63
+ { name: "dry-run", type: "boolean", description: "Show what would be sent and change nothing." },
64
+ { name: "url", type: "string", placeholder: "<url>", description: "MCP endpoint override (also SCRIBECLOCK_URL), e.g. a staging server." },
65
+ { name: "quiet", alias: "q", type: "boolean", description: "Only print results, no progress notes." },
66
+ { name: "no-color", type: "boolean", description: "Plain output (NO_COLOR is respected too)." },
67
+ { name: "debug", type: "boolean", description: "Show request timing and stack traces." },
68
+ { name: "reveal", type: "boolean", description: "Print secret values instead of masking them (vault/env)." },
69
+ { name: "help", alias: "h", type: "boolean", description: "Help for a command." },
70
+ { name: "version", alias: "V", type: "boolean", description: "Print the CLI version." },
71
+ ];
72
+ export function renderGlobalHelp(out, registry, version, toolCount) {
73
+ out.log(`${out.bold("scribeclock")} ${out.dim(`v${version}`)} — ScribeClock from the terminal`);
74
+ out.log("");
75
+ out.log(`${out.bold("Usage:")} scribeclock <command> [options]`);
76
+ out.log(` scribeclock <tool_name> [--arg value ...] ${out.dim("any server tool, see `scribeclock tools`")}`);
77
+ out.log("");
78
+ const commands = registry.list().filter((c) => !c.hidden);
79
+ for (const group of GROUPS) {
80
+ const inGroup = commands.filter((c) => c.group === group);
81
+ if (!inGroup.length && group !== "Raw tools")
82
+ continue;
83
+ out.log(out.bold(group));
84
+ const width = Math.max(...inGroup.map((c) => c.path.length), 18);
85
+ for (const c of inGroup)
86
+ out.log(` ${out.cyan(c.path.padEnd(width))} ${c.summary}`);
87
+ if (group === "Raw tools") {
88
+ out.log(` ${out.cyan("<tool_name>".padEnd(width))} Every MCP tool is a command${toolCount ? ` (${toolCount} available to you)` : ""}: scribeclock list_tasks --status open`);
89
+ }
90
+ out.log("");
91
+ }
92
+ out.log(out.bold("Global options"));
93
+ const w = Math.max(...GLOBAL_FLAGS.map((f) => flagLabel(f).length));
94
+ for (const f of GLOBAL_FLAGS)
95
+ out.log(` ${flagLabel(f).padEnd(w)} ${f.description}`);
96
+ out.log("");
97
+ out.log(out.bold("Exit codes") + " 0 ok · 1 error · 2 usage · 3 not authenticated · 4 conflict/overlap found · 5 missing scope");
98
+ out.log(out.dim("Docs: https://scribeclock.com/help/mcp-server · `scribeclock <command> --help` shows examples."));
99
+ }
100
+ export function flagLabel(f) {
101
+ const alias = f.alias ? `-${f.alias}, ` : " ";
102
+ const value = f.type === "boolean" ? "" : ` ${f.placeholder ?? (f.type === "array" ? "<a,b>" : f.type === "number" ? "<n>" : "<value>")}`;
103
+ return `${alias}--${f.name}${value}`;
104
+ }
105
+ export function renderCommandHelp(out, command) {
106
+ const positional = (command.positionals ?? []).map((p) => (p.required ? `<${p.name}${p.variadic ? "..." : ""}>` : `[${p.name}${p.variadic ? "..." : ""}]`)).join(" ");
107
+ out.log(`${out.bold("scribeclock " + command.path)}${positional ? " " + positional : ""}${command.flags?.length || command.allowUnknownFlags ? " [options]" : ""}`);
108
+ out.log("");
109
+ out.log(command.description ?? command.summary);
110
+ if (command.tool) {
111
+ const tools = Array.isArray(command.tool) ? command.tool : [command.tool];
112
+ out.log(out.dim(`Server tool${tools.length > 1 ? "s" : ""}: ${tools.join(", ")}`));
113
+ }
114
+ if (command.positionals?.length) {
115
+ out.log("");
116
+ out.log(out.bold("Arguments"));
117
+ const w = Math.max(...command.positionals.map((p) => p.name.length));
118
+ for (const p of command.positionals)
119
+ out.log(` ${p.name.padEnd(w)} ${p.description ?? ""}${p.required ? "" : out.dim(" (optional)")}`);
120
+ }
121
+ if (command.flags?.length) {
122
+ out.log("");
123
+ out.log(out.bold("Options"));
124
+ const w = Math.max(...command.flags.map((f) => flagLabel(f).length));
125
+ for (const f of command.flags) {
126
+ const extra = [f.choices ? `one of: ${f.choices.join(", ")}` : "", f.default !== undefined && f.default !== false ? `default: ${JSON.stringify(f.default)}` : "", f.required ? "required" : ""].filter(Boolean).join("; ");
127
+ out.log(` ${flagLabel(f).padEnd(w)} ${f.description}${extra ? out.dim(` (${extra})`) : ""}`);
128
+ }
129
+ }
130
+ if (command.examples?.length) {
131
+ out.log("");
132
+ out.log(out.bold("Examples"));
133
+ for (const e of command.examples)
134
+ out.log(` ${out.dim("$")} ${e}`);
135
+ }
136
+ out.log("");
137
+ out.log(out.dim("Global options: --json --org <id> --yes --dry-run --url <url> --quiet --no-color --debug --reveal"));
138
+ }
@@ -0,0 +1,220 @@
1
+ import { usageError } from "../cli/errors.js";
2
+ import { detectTaskKey } from "../git.js";
3
+ import { dueLabel, fmtDate, fmtDuration, relTime } from "../util/format.js";
4
+ import { truncate } from "../cli/output.js";
5
+ const truncateText = truncate;
6
+ import { EXIT } from "../types.js";
7
+ import { executeGuarded } from "./tools.js";
8
+ export function personName(p) {
9
+ if (!p || typeof p !== "object")
10
+ return typeof p === "string" ? p : "";
11
+ const o = p;
12
+ return o.name || o.email || o.id || "";
13
+ }
14
+ /** "731" → "#731"; "#731" and uuids pass through; "auto" → the task in the current branch / commit. */
15
+ export function taskKey(ctx, raw, opts = { allowAuto: true }) {
16
+ const s = (raw ?? "").trim();
17
+ if (!s || s === "auto" || s === ".") {
18
+ if (opts.allowAuto === false)
19
+ throw usageError("A task key is required (e.g. #123).");
20
+ const detected = detectTaskKey(ctx.cwd);
21
+ if (!detected)
22
+ throw usageError("No task given and none could be detected from the git branch or last commit.", "Pass the task key: scribeclock tasks show #123 — or name your branch like feat/123-thing.");
23
+ ctx.out.info(ctx.out.dim(`Using ${detected.key} (from the ${detected.source}).`));
24
+ return detected.key;
25
+ }
26
+ if (/^\d{1,6}$/.test(s))
27
+ return `#${s}`;
28
+ return s;
29
+ }
30
+ export const TASK_POSITIONAL = { name: "task", required: true, description: 'Task key (#123 or 123), uuid, or "auto" to detect it from the git branch / last commit.' };
31
+ export const PROJECT_FLAG = { name: "project", alias: "p", type: "string", placeholder: "<id|name>", description: "Project uuid or exact name (defaults to .scribeclock.json → project)." };
32
+ export function projectArg(ctx, inv, opts = {}) {
33
+ const fromFlag = typeof inv.flags.project === "string" ? inv.flags.project : undefined;
34
+ const fromPos = opts.positional !== undefined ? inv.positionals[opts.positional] : undefined;
35
+ const value = fromFlag || fromPos || ctx.repo.config.project;
36
+ if (!value && opts.required)
37
+ throw usageError("A project is required.", "Pass --project <id|name>, or put {\"project\": \"<id|name>\"} in .scribeclock.json at the repository root.");
38
+ return value;
39
+ }
40
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+ /**
42
+ * Some tools (list_tasks, create_task, update_task, get_workload) take a project uuid only. This turns
43
+ * an exact project name into its id through list_projects; uuids pass through untouched.
44
+ */
45
+ export async function resolveProjectId(ctx, value) {
46
+ if (!value)
47
+ return undefined;
48
+ if (UUID.test(value))
49
+ return value;
50
+ const data = await ctx.call("list_projects", { query: value, limit: 50 });
51
+ const projects = (data.projects ?? []);
52
+ const exact = projects.filter((p) => String(p.name ?? "").toLowerCase() === value.toLowerCase());
53
+ if (exact.length === 1)
54
+ return String(exact[0].id);
55
+ if (exact.length > 1)
56
+ throw usageError(`"${value}" matches ${exact.length} projects; pass the project id (scribeclock projects list "${value}").`);
57
+ if (projects.length === 1)
58
+ return String(projects[0].id);
59
+ throw usageError(`No project named "${value}".`, projects.length ? `Did you mean: ${projects.slice(0, 5).map((p) => String(p.name)).join(", ")}?` : "scribeclock projects list shows the names.");
60
+ }
61
+ export async function projectIdArg(ctx, inv, opts = {}) {
62
+ return resolveProjectId(ctx, projectArg(ctx, inv, opts));
63
+ }
64
+ /** JSON mode prints the server result untouched; otherwise the human renderer runs. */
65
+ export function emit(ctx, data, human) {
66
+ if (ctx.flags.json)
67
+ ctx.out.jsonOut(data);
68
+ else
69
+ human();
70
+ return EXIT.OK;
71
+ }
72
+ /** Guarded call + emit, for aliases of sensitive tools. */
73
+ export async function guardedEmit(ctx, tool, args, human) {
74
+ const result = await executeGuarded(ctx, tool, args);
75
+ if (result.status === "cancelled")
76
+ return EXIT.ERROR;
77
+ if (result.status === "dry_run")
78
+ return EXIT.OK;
79
+ if (ctx.flags.json)
80
+ ctx.out.jsonOut(result.data);
81
+ else
82
+ human(result.data, result);
83
+ return EXIT.OK;
84
+ }
85
+ export function statusPaint(ctx, status) {
86
+ const s = String(status ?? "");
87
+ if (s === "in-progress" || s === "in_progress")
88
+ return ctx.out.cyan("in progress");
89
+ if (s === "completed")
90
+ return ctx.out.green("completed");
91
+ if (s === "cancelled")
92
+ return ctx.out.dim("cancelled");
93
+ if (s === "pending")
94
+ return ctx.out.dim("pending");
95
+ return s;
96
+ }
97
+ export function priorityPaint(ctx, p) {
98
+ const s = String(p ?? "");
99
+ if (s === "high")
100
+ return ctx.out.red("high");
101
+ if (s === "medium")
102
+ return ctx.out.yellow("medium");
103
+ if (s === "low")
104
+ return ctx.out.green("low");
105
+ return s;
106
+ }
107
+ export function duePaint(ctx, iso, overdue) {
108
+ if (!iso)
109
+ return "";
110
+ const label = dueLabel(String(iso), ctx.now());
111
+ if (overdue === true || label.startsWith("overdue"))
112
+ return ctx.out.red(label);
113
+ if (label === "today")
114
+ return ctx.out.yellow(label);
115
+ return label;
116
+ }
117
+ export function renderTaskTable(ctx, tasks, opts = {}) {
118
+ if (!tasks.length) {
119
+ ctx.out.log(ctx.out.dim(" (no tasks)"));
120
+ return;
121
+ }
122
+ const headers = ["key", "title", "status", "pri", "due"];
123
+ if (opts.showAssignee !== false)
124
+ headers.push("assignee");
125
+ if (opts.showProject !== false)
126
+ headers.push("project");
127
+ const rows = tasks.map((t) => {
128
+ const row = [t.key ?? "", truncate(String(t.title ?? ""), 56), statusPaint(ctx, t.status), priorityPaint(ctx, t.priority), duePaint(ctx, t.due_date, t.is_overdue)];
129
+ if (opts.showAssignee !== false)
130
+ row.push(t.assignees?.length ? t.assignees.map(personName).join(", ") : personName(t.assignee));
131
+ if (opts.showProject !== false)
132
+ row.push(t.project?.name ?? "");
133
+ return row;
134
+ });
135
+ ctx.out.table(headers, rows, { maxWidth: 60 });
136
+ }
137
+ export function renderTaskDetail(ctx, data, opts = {}) {
138
+ const t = (data.task ?? data);
139
+ const out = ctx.out;
140
+ out.log(`${out.bold(String(t.key ?? ""))} ${out.bold(String(t.title ?? ""))}`);
141
+ const org = t.organization;
142
+ const time = data.time;
143
+ out.kv([
144
+ ["status", statusPaint(ctx, t.status)],
145
+ ["priority", priorityPaint(ctx, t.priority)],
146
+ ["assignee", t.assignees?.length ? t.assignees.map(personName).join(", ") : personName(t.assignee) || out.dim("unassigned")],
147
+ ["due", t.due_date ? `${fmtDate(String(t.due_date))} (${duePaint(ctx, t.due_date, t.is_overdue)})` : ""],
148
+ ["project", t.project?.name ? `${t.project.name}${t.project.github_repo ? out.dim(` · ${String(t.project.github_repo)}`) : ""}` : ""],
149
+ ["organization", org?.name],
150
+ ["created", t.created_at ? `${fmtDate(String(t.created_at), { time: true })} by ${personName(t.created_by)}` : ""],
151
+ ["updated", t.updated_at ? relTime(String(t.updated_at), ctx.now()) : ""],
152
+ ["time logged", time && Number(time.total_hours) > 0 ? `${Number(time.total_hours).toFixed(2)} h (${String(time.entry_count)} entries)` : t.time_logged_hours ? `${String(t.time_logged_hours)} h` : ""],
153
+ ["github", t.github ? JSON.stringify(t.github) : ""],
154
+ ["url", t.url],
155
+ ]);
156
+ const description = typeof t.description === "string" ? t.description : "";
157
+ if (description) {
158
+ out.log("");
159
+ const lines = description.split("\n");
160
+ const shown = opts.full ? lines : lines.slice(0, 30);
161
+ for (const line of shown)
162
+ out.log(` ${line}`);
163
+ if (!opts.full && (lines.length > 30 || t.description_truncated))
164
+ out.log(out.dim(` … (${lines.length} lines; --full for everything)`));
165
+ }
166
+ const attachments = Array.isArray(t.attachments) ? t.attachments : [];
167
+ if (attachments.length) {
168
+ out.log("");
169
+ out.log(out.bold(`Attachments (${attachments.length})`));
170
+ attachments.forEach((a, i) => out.log(` ${i}. ${String(a.name ?? a.title ?? a.url ?? "")}${a.url ? out.dim(` ${String(a.url)}`) : ""}`));
171
+ }
172
+ const comments = Array.isArray(data.comments) ? data.comments : [];
173
+ if (comments.length) {
174
+ out.log("");
175
+ out.log(out.bold(`Comments (${comments.length})`));
176
+ for (const c of comments.slice(-10)) {
177
+ out.log(` ${out.cyan(personName(c.author ?? c.created_by ?? c.user))} ${out.dim(fmtDate(String(c.created_at ?? ""), { time: true }))}`);
178
+ for (const line of String(c.body ?? c.content ?? c.note ?? "").split("\n"))
179
+ out.log(` ${line}`);
180
+ }
181
+ }
182
+ const gh = Array.isArray(data.github_activity) ? data.github_activity : [];
183
+ if (gh.length) {
184
+ out.log("");
185
+ out.log(out.bold(`GitHub activity (${gh.length})`));
186
+ for (const g of gh.slice(0, 10))
187
+ out.log(` ${String(g.kind ?? g.type ?? "")} ${truncateText(String(g.title ?? g.message ?? ""), 80)} ${out.dim(String(g.url ?? ""))}`);
188
+ }
189
+ if (time && Array.isArray(time.by_person) && time.by_person.length) {
190
+ out.log("");
191
+ out.log(out.bold("Time by person"));
192
+ for (const p of time.by_person)
193
+ out.log(` ${personName(p.person ?? p)} ${Number(p.hours ?? 0).toFixed(2)} h`);
194
+ }
195
+ }
196
+ export function fmtElapsed(seconds) {
197
+ return fmtDuration(Number(seconds ?? 0));
198
+ }
199
+ /** Builds a Command object with less ceremony. */
200
+ export function cmd(spec) {
201
+ return spec;
202
+ }
203
+ export function str(v) {
204
+ return typeof v === "string" && v !== "" ? v : undefined;
205
+ }
206
+ export function num(v) {
207
+ return typeof v === "number" ? v : undefined;
208
+ }
209
+ export function bool(v) {
210
+ return typeof v === "boolean" ? v : undefined;
211
+ }
212
+ export function list(v) {
213
+ return Array.isArray(v) && v.length ? v.map(String) : undefined;
214
+ }
215
+ export function requirePositional(inv, index, what, example) {
216
+ const v = inv.positionals[index];
217
+ if (!v)
218
+ throw usageError(`${what} is required.`, `Example: ${example}`);
219
+ return v;
220
+ }
@@ -0,0 +1,228 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError, authError, usageError } from "../cli/errors.js";
3
+ import { ask, askHidden } from "../cli/confirm.js";
4
+ import { DEFAULT_SCOPES, ALL_SCOPES, buildAuthorizeUrl, discover, exchangeCode, expiresAt, openBrowser, pkcePair, randomState, registerClient, revokeToken, waitForCallback } from "../auth/oauth.js";
5
+ import { McpClient, parseToolResult } from "../mcp/client.js";
6
+ import { EXIT } from "../types.js";
7
+ import { SETTINGS_URL } from "../config.js";
8
+ import { fmtDate, relTime } from "../util/format.js";
9
+ const TOKEN_SHAPE = /^scmcp_[0-9a-f]{48}$/;
10
+ async function verifyToken(ctx, token) {
11
+ const client = new McpClient({ endpoint: ctx.endpoint, tokens: { getToken: async () => token, refresh: async () => null }, fetch: ctx.fetch, clientVersion: ctx.version });
12
+ const result = await client.rpc("tools/call", { name: "get_viewer", arguments: {} });
13
+ const outcome = parseToolResult(result);
14
+ if (!outcome.ok)
15
+ throw new CliError(`The token was accepted but get_viewer failed: ${outcome.message}`, { id: outcome.error });
16
+ return outcome.data;
17
+ }
18
+ function describeViewer(ctx, viewer) {
19
+ const v = (viewer.viewer ?? {});
20
+ const token = (viewer.token ?? {});
21
+ const orgs = Array.isArray(viewer.organizations) ? viewer.organizations : [];
22
+ ctx.out.log(`${ctx.out.green("✓")} Logged in as ${ctx.out.bold(String(v.name ?? v.email ?? "?"))}${v.email && v.name ? ctx.out.dim(` <${String(v.email)}>`) : ""}`);
23
+ if (orgs.length)
24
+ ctx.out.log(` Organizations: ${orgs.map((o) => String(o.name ?? o.id)).join(", ")}`);
25
+ if (Array.isArray(token.scopes))
26
+ ctx.out.log(` Scopes: ${token.scopes.join(" ")}`);
27
+ }
28
+ export const loginCommand = {
29
+ path: "login",
30
+ group: "Getting started",
31
+ summary: "Sign in with your browser (OAuth + PKCE) or with a personal access token.",
32
+ description: "Opens scribeclock.com in your browser; you sign in, pick the permissions and organizations, and the CLI receives its tokens on 127.0.0.1. Tokens go to the OS keychain (or a 0600 file). For CI and headless machines use --token, or set SCRIBECLOCK_TOKEN (it always wins over a stored login).",
33
+ flags: [
34
+ { name: "token", alias: "t", type: "string", placeholder: "<scmcp_…|->", description: "Use a personal access token instead of OAuth (- reads it from stdin; omit the value to be prompted)." },
35
+ { name: "scopes", alias: "s", type: "array", placeholder: "<a,b>", description: `Scopes to request (default: everything except Cloudflare). Known: ${ALL_SCOPES.join(", ")}.` },
36
+ { name: "all", type: "boolean", description: "Request every scope, Cloudflare included." },
37
+ { name: "no-browser", type: "boolean", description: "Print the sign-in URL instead of opening a browser." },
38
+ { name: "paste", type: "boolean", description: "Headless: print the URL, then paste the redirect URL (or code) back here instead of listening on a port." },
39
+ { name: "timeout", type: "number", placeholder: "<seconds>", description: "How long to wait for the browser (default 300).", default: 300 },
40
+ ],
41
+ examples: ["scribeclock login", "scribeclock login --scopes tasks:read,tasks:write", "scribeclock login --all", "echo $TOKEN | scribeclock login --token -", "SCRIBECLOCK_TOKEN=scmcp_… scribeclock whoami"],
42
+ async run(ctx, inv) {
43
+ const requested = inv.flags.all ? [...ALL_SCOPES] : Array.isArray(inv.flags.scopes) && inv.flags.scopes.length ? inv.flags.scopes : [...DEFAULT_SCOPES];
44
+ const unknown = requested.filter((s) => !ALL_SCOPES.includes(s));
45
+ if (unknown.length)
46
+ throw usageError(`Unknown scope${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, `Known scopes: ${ALL_SCOPES.join(", ")}`);
47
+ const wantsToken = inv.rest.includes("--token") || inv.rest.includes("-t") || inv.rest.some((t) => t.startsWith("--token="));
48
+ if (wantsToken || typeof inv.flags.token === "string") {
49
+ let token = typeof inv.flags.token === "string" ? inv.flags.token.trim() : "";
50
+ if (token === "-")
51
+ token = readFileSync(0, "utf8").trim();
52
+ if (!token) {
53
+ if (!ctx.stdin.isTTY)
54
+ throw usageError("--token needs a value when no terminal is attached.", "Pipe it: echo $TOKEN | scribeclock login --token -");
55
+ ctx.out.info(`Create a personal access token at ${SETTINGS_URL}.`);
56
+ token = (await askHidden(ctx, "Paste the token (input is hidden): ")).trim();
57
+ }
58
+ if (!TOKEN_SHAPE.test(token))
59
+ throw usageError("That does not look like a ScribeClock token (scmcp_ followed by 48 hex characters).");
60
+ const viewer = await verifyToken(ctx, token);
61
+ const scopes = Array.isArray(viewer.token?.scopes) ? viewer.token.scopes : undefined;
62
+ const creds = { version: 1, endpoint: ctx.endpoint, kind: "pat", access_token: token, scopes, saved_at: ctx.now().toISOString() };
63
+ await ctx.store.save(ctx.profile, creds);
64
+ ctx.tokens.invalidate();
65
+ ctx.catalog.clearAll();
66
+ if (ctx.flags.json)
67
+ ctx.out.jsonOut({ ok: true, kind: "pat", store: ctx.store.backend, viewer: viewer.viewer, organizations: viewer.organizations, scopes });
68
+ else {
69
+ describeViewer(ctx, viewer);
70
+ ctx.out.info(ctx.out.dim(`Token stored in the ${ctx.store.backend === "keychain" ? "system keychain" : "credentials file"} for ${ctx.profile}.`));
71
+ }
72
+ return EXIT.OK;
73
+ }
74
+ if (ctx.env.SCRIBECLOCK_TOKEN)
75
+ ctx.out.warn("SCRIBECLOCK_TOKEN is set and will keep taking precedence over this login.");
76
+ ctx.out.info(`Discovering ${ctx.endpoint}…`);
77
+ const meta = await discover(ctx.endpoint, ctx.fetch);
78
+ const clients = (ctx.config.read().oauth_clients ?? {});
79
+ let clientId = clients[meta.issuer];
80
+ if (!clientId) {
81
+ clientId = await registerClient(meta, ctx.fetch, { clientName: "ScribeClock CLI", scopes: requested });
82
+ ctx.config.write({ oauth_clients: { ...clients, [meta.issuer]: clientId } });
83
+ }
84
+ const { verifier, challenge } = pkcePair();
85
+ const state = randomState();
86
+ let redirectUri;
87
+ let codePromise;
88
+ if (inv.flags.paste || !ctx.stdin.isTTY && inv.flags["no-browser"]) {
89
+ redirectUri = "http://127.0.0.1:1/callback";
90
+ codePromise = Promise.reject(new Error("paste mode"));
91
+ codePromise.catch(() => undefined);
92
+ }
93
+ else {
94
+ const listener = await waitForCallback({ state, timeoutMs: Number(inv.flags.timeout) * 1000 });
95
+ redirectUri = listener.redirectUri;
96
+ codePromise = listener.code;
97
+ }
98
+ const url = buildAuthorizeUrl(meta, { client_id: clientId, redirect_uri: redirectUri, state, challenge, scopes: requested });
99
+ if (inv.flags["no-browser"] || inv.flags.paste || !openBrowser(url, ctx.platform)) {
100
+ ctx.out.info("Open this URL in a browser to sign in:\n\n " + url + "\n");
101
+ }
102
+ else {
103
+ ctx.out.info("Your browser should open ScribeClock. If it does not, open this URL:\n\n " + ctx.out.dim(url) + "\n");
104
+ }
105
+ let code;
106
+ if (inv.flags.paste || (!ctx.stdin.isTTY && inv.flags["no-browser"])) {
107
+ if (!ctx.stdin.isTTY)
108
+ throw usageError("--paste needs an interactive terminal to paste into.");
109
+ const pasted = (await ask(ctx, "After approving, paste the URL the browser was sent to (or just the code): ")).trim();
110
+ const m = /[?&]code=([^&\s]+)/.exec(pasted);
111
+ const st = /[?&]state=([^&\s]+)/.exec(pasted);
112
+ if (st && decodeURIComponent(st[1]) !== state)
113
+ throw new CliError("The pasted state does not match this login attempt.", { id: "state_mismatch" });
114
+ code = m ? decodeURIComponent(m[1]) : pasted;
115
+ if (!code)
116
+ throw usageError("No authorization code found.");
117
+ }
118
+ else {
119
+ ctx.out.info(ctx.out.dim("Waiting for the sign-in to finish…"));
120
+ try {
121
+ code = await codePromise;
122
+ }
123
+ catch (err) {
124
+ if (err instanceof CliError)
125
+ throw err;
126
+ throw new CliError(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`, { id: "login_failed" });
127
+ }
128
+ }
129
+ const tokens = await exchangeCode(ctx.fetch, meta, { code, verifier, client_id: clientId, redirect_uri: redirectUri });
130
+ const creds = {
131
+ version: 1,
132
+ endpoint: ctx.endpoint,
133
+ kind: "oauth",
134
+ access_token: tokens.access_token,
135
+ refresh_token: tokens.refresh_token,
136
+ expires_at: expiresAt(tokens.expires_in, ctx.now()),
137
+ scopes: tokens.scope ? tokens.scope.split(/\s+/).filter(Boolean) : requested,
138
+ issuer: meta.issuer,
139
+ client_id: clientId,
140
+ token_endpoint: meta.token_endpoint,
141
+ revocation_endpoint: meta.revocation_endpoint,
142
+ saved_at: ctx.now().toISOString(),
143
+ };
144
+ await ctx.store.save(ctx.profile, creds);
145
+ ctx.tokens.invalidate();
146
+ ctx.catalog.clearAll();
147
+ const viewer = await verifyToken(ctx, creds.access_token);
148
+ if (ctx.flags.json)
149
+ ctx.out.jsonOut({ ok: true, kind: "oauth", store: ctx.store.backend, viewer: viewer.viewer, organizations: viewer.organizations, scopes: creds.scopes, expires_at: creds.expires_at });
150
+ else {
151
+ describeViewer(ctx, viewer);
152
+ const granted = creds.scopes ?? [];
153
+ const missing = requested.filter((s) => !granted.includes(s));
154
+ if (missing.length)
155
+ ctx.out.info(ctx.out.dim(`Not granted on the consent screen: ${missing.join(", ")}`));
156
+ ctx.out.info(ctx.out.dim(`Tokens stored in the ${ctx.store.backend === "keychain" ? "system keychain" : "credentials file"} for ${ctx.profile}. Access tokens refresh automatically for 90 days.`));
157
+ }
158
+ return EXIT.OK;
159
+ },
160
+ };
161
+ export const logoutCommand = {
162
+ path: "logout",
163
+ group: "Getting started",
164
+ summary: "Revoke the stored tokens and forget the login.",
165
+ async run(ctx) {
166
+ const creds = await ctx.store.load(ctx.profile);
167
+ if (!creds) {
168
+ if (ctx.flags.json)
169
+ ctx.out.jsonOut({ ok: true, revoked: false, message: "No stored login." });
170
+ else
171
+ ctx.out.log("No stored login for " + ctx.profile + (ctx.env.SCRIBECLOCK_TOKEN ? " (SCRIBECLOCK_TOKEN is set in the environment; unset it to stop using it)." : "."));
172
+ return EXIT.OK;
173
+ }
174
+ let revoked = false;
175
+ if (creds.kind === "oauth" && creds.revocation_endpoint) {
176
+ try {
177
+ revoked = await revokeToken(ctx.fetch, creds.revocation_endpoint, creds.refresh_token ?? creds.access_token, creds.client_id, creds.refresh_token ? "refresh_token" : "access_token");
178
+ if (creds.refresh_token)
179
+ await revokeToken(ctx.fetch, creds.revocation_endpoint, creds.access_token, creds.client_id, "access_token");
180
+ }
181
+ catch {
182
+ ctx.out.warn("Could not reach the server to revoke the tokens; they were removed locally and will expire on their own.");
183
+ }
184
+ }
185
+ await ctx.store.clear(ctx.profile);
186
+ ctx.tokens.invalidate();
187
+ ctx.catalog.clearAll();
188
+ if (ctx.flags.json)
189
+ ctx.out.jsonOut({ ok: true, revoked, kind: creds.kind });
190
+ else {
191
+ ctx.out.log(`Logged out of ${ctx.profile}.${creds.kind === "oauth" ? (revoked ? " Tokens revoked." : "") : " The personal access token itself stays valid until you revoke it in Settings → AI agents."}`);
192
+ if (ctx.env.SCRIBECLOCK_TOKEN)
193
+ ctx.out.warn("SCRIBECLOCK_TOKEN is still set in this shell.");
194
+ }
195
+ return EXIT.OK;
196
+ },
197
+ };
198
+ export const whoamiCommand = {
199
+ path: "whoami",
200
+ group: "Getting started",
201
+ summary: "Who you are, your organizations, scopes and token status.",
202
+ tool: "get_viewer",
203
+ async run(ctx) {
204
+ const viewer = await ctx.call("get_viewer", {}, { skipOrg: true });
205
+ const creds = ctx.env.SCRIBECLOCK_TOKEN ? null : await ctx.store.load(ctx.profile);
206
+ const source = ctx.env.SCRIBECLOCK_TOKEN ? "env:SCRIBECLOCK_TOKEN" : creds ? `${creds.kind} (${ctx.store.backend})` : "unknown";
207
+ if (ctx.flags.json) {
208
+ ctx.out.jsonOut({ ...viewer, cli: { endpoint: ctx.endpoint, token_source: source, expires_at: creds?.expires_at ?? null, org_override: ctx.org() ?? null } });
209
+ return EXIT.OK;
210
+ }
211
+ const v = (viewer.viewer ?? {});
212
+ const token = (viewer.token ?? {});
213
+ const orgs = Array.isArray(viewer.organizations) ? viewer.organizations : [];
214
+ ctx.out.log(`${ctx.out.bold(String(v.name ?? "?"))}${v.email ? ctx.out.dim(` <${String(v.email)}>`) : ""}`);
215
+ ctx.out.kv([
216
+ ["endpoint", ctx.endpoint],
217
+ ["token", `${source}${token.kind ? ` · server kind ${String(token.kind)}` : ""}${creds?.expires_at ? ` · access token ${relTime(creds.expires_at, ctx.now())} (${fmtDate(creds.expires_at, { time: true })})` : ""}`],
218
+ ["scopes", Array.isArray(token.scopes) ? token.scopes.join(" ") : "legacy (tasks + projects)"],
219
+ ["acting in", ctx.org() ?? (viewer.requires_organization_id ? "no default — pass --org" : "default organization")],
220
+ ]);
221
+ if (orgs.length) {
222
+ ctx.out.log("");
223
+ ctx.out.table(["organization", "id", "role", "open tasks"], orgs.map((o) => [o.name, o.id, o.role, o.open_task_count ?? o.open_tasks ?? ""]));
224
+ }
225
+ return EXIT.OK;
226
+ },
227
+ };
228
+ export { authError };