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,270 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { usageError } from "../cli/errors.js";
3
+ import { EXIT } from "../types.js";
4
+ import { fmtDate } from "../util/format.js";
5
+ import { bool, cmd, emit, guardedEmit, list, num, requirePositional, str } from "./_shared.js";
6
+ const ACCOUNT_FLAG = { name: "account", type: "string", placeholder: "<id>", description: "Cloudflare account id (default: the organization's primary account)." };
7
+ export const cfAccounts = cmd({
8
+ path: "cf accounts",
9
+ group: "Cloudflare",
10
+ summary: "Cloudflare accounts the organization's connection can see.",
11
+ tool: "cloudflare_accounts",
12
+ async run(ctx) {
13
+ const data = await ctx.call("cloudflare_accounts", {});
14
+ return emit(ctx, data, () => {
15
+ const accounts = (data.accounts ?? []);
16
+ ctx.out.table(["id", "name", "primary"], accounts.map((a) => [a.id, a.name, a.is_primary || a.primary ? "yes" : ""]));
17
+ ctx.out.info(ctx.out.dim(data.can_write ? "This token may change things (cloudflare:write)." : "Read-only token (cloudflare:read)."));
18
+ });
19
+ },
20
+ });
21
+ export const cfZones = cmd({
22
+ path: "cf zones",
23
+ group: "Cloudflare",
24
+ summary: "Zones (domains) in the account.",
25
+ tool: "cloudflare_zones",
26
+ positionals: [{ name: "name", description: "Exact zone name filter." }],
27
+ flags: [ACCOUNT_FLAG],
28
+ async run(ctx, inv) {
29
+ const data = await ctx.call("cloudflare_zones", { account_id: str(inv.flags.account), name: inv.positionals[0] });
30
+ return emit(ctx, data, () => {
31
+ const zones = (data.zones ?? data.result ?? []);
32
+ ctx.out.table(["id", "name", "status", "plan", "name servers"], zones.map((z) => [z.id, z.name, z.status, z.plan?.name ?? z.plan, z.name_servers?.join(", ")]));
33
+ });
34
+ },
35
+ });
36
+ export const cfDns = cmd({
37
+ path: "cf dns",
38
+ group: "Cloudflare",
39
+ summary: "DNS records of a zone: cf dns example.com [--type A --name api].",
40
+ tool: "cloudflare_dns_records",
41
+ positionals: [{ name: "zone", required: true, description: "Zone id or domain name." }],
42
+ flags: [
43
+ { name: "type", alias: "t", type: "string", description: "Record type filter (A, CNAME, TXT…)." },
44
+ { name: "name", type: "string", description: "Record name filter." },
45
+ { name: "content", type: "string", description: "Record content filter." },
46
+ { name: "page", type: "number", description: "Page (Cloudflare pages at per_page)." },
47
+ { name: "per-page", type: "number", description: "Records per page (max 100)." },
48
+ ],
49
+ async run(ctx, inv) {
50
+ const zone = requirePositional(inv, 0, "A zone", "scribeclock cf dns example.com");
51
+ const data = await ctx.call("cloudflare_dns_records", { zone, type: str(inv.flags.type), name: str(inv.flags.name), content: str(inv.flags.content), page: num(inv.flags.page), per_page: num(inv.flags["per-page"]) });
52
+ return emit(ctx, data, () => renderDns(ctx, (data.records ?? data.result ?? [])));
53
+ },
54
+ });
55
+ function renderDns(ctx, records) {
56
+ ctx.out.table(["type", "name", "content", "ttl", "proxied", "id"], records.map((r) => [r.type, r.name, r.content, r.ttl === 1 ? "auto" : r.ttl, r.proxied ? "yes" : "", r.id]), { maxWidth: 60 });
57
+ }
58
+ export const cfDnsSet = cmd({
59
+ path: "cf dns set",
60
+ group: "Cloudflare",
61
+ summary: "Create or update a DNS record (live; asks first): cf dns set example.com A api 1.2.3.4 [--proxied].",
62
+ tool: "cloudflare_dns_upsert",
63
+ positionals: [{ name: "zone", required: true }, { name: "type", required: true, description: "A, AAAA, CNAME, TXT, MX, NS, SRV, CAA, PTR, HTTPS, SVCB" }, { name: "name", required: true, description: "Relative (api) or FQDN; @ = apex." }, { name: "content", required: true }],
64
+ flags: [
65
+ { name: "ttl", type: "number", description: "1 = automatic, else 60–86400." },
66
+ { name: "proxied", type: "boolean", description: "Proxy through Cloudflare (orange cloud)." },
67
+ { name: "priority", type: "number", description: "MX/SRV priority." },
68
+ { name: "comment", type: "string", description: "Record comment (≤ 100 chars)." },
69
+ { name: "replace", type: "boolean", description: "Update an existing TXT/MX of the same name instead of adding one." },
70
+ ],
71
+ examples: ["scribeclock cf dns set example.com CNAME www example.pages.dev --proxied", "scribeclock cf dns set example.com TXT @ 'v=spf1 include:_spf.google.com ~all' --replace --dry-run"],
72
+ async run(ctx, inv) {
73
+ const [zone, type, name, content] = [0, 1, 2, 3].map((i) => requirePositional(inv, i, ["A zone", "A record type", "A record name", "Record content"][i], "scribeclock cf dns set example.com A api 1.2.3.4"));
74
+ return guardedEmit(ctx, "cloudflare_dns_upsert", { zone, type: type.toUpperCase(), name, content, ttl: num(inv.flags.ttl), proxied: bool(inv.flags.proxied), priority: num(inv.flags.priority), comment: str(inv.flags.comment), replace: bool(inv.flags.replace) }, (data) => {
75
+ const after = (data.after ?? data.record);
76
+ ctx.out.log(`${ctx.out.green("✓")} ${data.before ? "Updated" : "Created"} ${type.toUpperCase()} ${name} → ${content}${after?.id ? ctx.out.dim(` (${String(after.id)})`) : ""}`);
77
+ });
78
+ },
79
+ });
80
+ export const cfDnsRm = cmd({
81
+ path: "cf dns rm",
82
+ group: "Cloudflare",
83
+ summary: "Delete a DNS record (live; asks first): cf dns rm example.com --id <record> | --type A --name api.",
84
+ tool: "cloudflare_dns_delete",
85
+ positionals: [{ name: "zone", required: true }],
86
+ flags: [
87
+ { name: "id", type: "string", description: "Record id." },
88
+ { name: "type", alias: "t", type: "string", description: "Record type (with --name)." },
89
+ { name: "name", type: "string", description: "Record name (with --type)." },
90
+ ],
91
+ async run(ctx, inv) {
92
+ const zone = requirePositional(inv, 0, "A zone", "scribeclock cf dns rm example.com --id <record>");
93
+ if (!inv.flags.id && !(inv.flags.type && inv.flags.name))
94
+ throw usageError("Pass --id, or --type and --name.");
95
+ return guardedEmit(ctx, "cloudflare_dns_delete", { zone, record_id: str(inv.flags.id), type: str(inv.flags.type), name: str(inv.flags.name) }, () => ctx.out.log(`${ctx.out.green("✓")} Deleted ${String(inv.flags.id ?? `${String(inv.flags.type)} ${String(inv.flags.name)}`)} in ${zone}.`));
96
+ },
97
+ });
98
+ export function dnsDiff(zoneName, live, desired) {
99
+ const fq = (n) => (n === "@" ? zoneName : n.endsWith(`.${zoneName}`) || n === zoneName ? n : `${n}.${zoneName}`).toLowerCase();
100
+ const create = [];
101
+ const update = [];
102
+ const same = [];
103
+ for (const d of desired) {
104
+ const matches = live.filter((r) => String(r.type).toUpperCase() === d.type.toUpperCase() && fq(String(r.name)) === fq(d.name));
105
+ const exact = matches.find((r) => String(r.content) === d.content && (d.ttl === undefined || r.ttl === d.ttl) && (d.proxied === undefined || Boolean(r.proxied) === d.proxied));
106
+ if (exact)
107
+ same.push(d);
108
+ else if (matches.length === 1 || (matches.length && !["TXT", "MX"].includes(d.type.toUpperCase())))
109
+ update.push({ from: matches[0], to: d });
110
+ else
111
+ create.push(d);
112
+ }
113
+ return { create, update, same };
114
+ }
115
+ export const cfDnsDiff = cmd({
116
+ path: "cf dns diff",
117
+ group: "Cloudflare",
118
+ summary: "Compare a zone's live DNS with a desired-state JSON file; --apply upserts the differences (asks). Exit 4 when there are differences.",
119
+ tool: ["cloudflare_dns_records", "cloudflare_dns_upsert"],
120
+ positionals: [{ name: "zone", required: true, description: "Zone id or domain." }, { name: "file", required: true, description: 'JSON array of {type,name,content,ttl?,proxied?,priority?}.' }],
121
+ flags: [{ name: "apply", type: "boolean", description: "Apply the creates/updates (each one confirmed, or --yes)." }],
122
+ examples: ["scribeclock cf dns diff example.com dns.json", "scribeclock cf dns diff example.com dns.json --apply --yes"],
123
+ async run(ctx, inv) {
124
+ const zone = requirePositional(inv, 0, "A zone", "scribeclock cf dns diff example.com dns.json");
125
+ const file = requirePositional(inv, 1, "A desired-state file", "scribeclock cf dns diff example.com dns.json");
126
+ const desired = JSON.parse(readFileSync(file, "utf8"));
127
+ if (!Array.isArray(desired))
128
+ throw usageError("The file must contain a JSON array of records.");
129
+ const live = await ctx.call("cloudflare_dns_records", { zone, per_page: 100 });
130
+ const records = (live.records ?? live.result ?? []);
131
+ const zoneName = String(live.zone?.name ?? live.zone_name ?? records[0]?.zone_name ?? zone);
132
+ const diff = dnsDiff(zoneName, records, desired);
133
+ const changes = diff.create.length + diff.update.length;
134
+ if (ctx.flags.json && !inv.flags.apply) {
135
+ ctx.out.jsonOut({ ok: true, zone: zoneName, changes, ...diff });
136
+ return changes ? EXIT.CONFLICT : EXIT.OK;
137
+ }
138
+ for (const c of diff.create)
139
+ ctx.out.log(ctx.out.green(`+ ${c.type} ${c.name} → ${c.content}${c.proxied ? " (proxied)" : ""}`));
140
+ for (const u of diff.update)
141
+ ctx.out.log(ctx.out.yellow(`~ ${u.to.type} ${u.to.name}: ${String(u.from.content)} → ${u.to.content}`));
142
+ for (const s of diff.same)
143
+ ctx.out.log(ctx.out.dim(`= ${s.type} ${s.name} → ${s.content}`));
144
+ if (!changes) {
145
+ ctx.out.info("Live DNS matches the file.");
146
+ return EXIT.OK;
147
+ }
148
+ if (!inv.flags.apply) {
149
+ ctx.out.info(ctx.out.dim(`${changes} difference${changes === 1 ? "" : "s"}. Add --apply to upsert them.`));
150
+ return EXIT.CONFLICT;
151
+ }
152
+ const applied = [];
153
+ for (const item of [...diff.create, ...diff.update.map((u) => u.to)]) {
154
+ const r = await guardedEmit(ctx, "cloudflare_dns_upsert", { zone, type: item.type.toUpperCase(), name: item.name, content: item.content, ttl: item.ttl, proxied: item.proxied, priority: item.priority, replace: ["TXT", "MX"].includes(item.type.toUpperCase()) ? true : undefined }, (d) => {
155
+ applied.push(d);
156
+ ctx.out.log(`${ctx.out.green("✓")} ${item.type} ${item.name} → ${item.content}`);
157
+ });
158
+ if (r !== EXIT.OK)
159
+ return r;
160
+ }
161
+ return EXIT.OK;
162
+ },
163
+ });
164
+ export const cfPages = cmd({
165
+ path: "cf pages",
166
+ group: "Cloudflare",
167
+ summary: "Pages projects with their domains, production branch and latest deployment.",
168
+ tool: "cloudflare_pages_projects",
169
+ flags: [ACCOUNT_FLAG],
170
+ async run(ctx, inv) {
171
+ const data = await ctx.call("cloudflare_pages_projects", { account_id: str(inv.flags.account) });
172
+ return emit(ctx, data, () => {
173
+ const projects = (data.projects ?? data.result ?? []);
174
+ ctx.out.table(["name", "domains", "branch", "latest", "status"], projects.map((p) => {
175
+ const latest = (p.latest_deployment ?? p.canonical_deployment);
176
+ return [p.name, p.domains?.join(", "), p.production_branch, latest ? fmtDate(String(latest.created_on ?? latest.created_at ?? ""), { time: true }) : "", latest ? (latest.latest_stage?.status ?? latest.status ?? "") : ""];
177
+ }));
178
+ });
179
+ },
180
+ });
181
+ export const cfDeployments = cmd({
182
+ path: "cf deployments",
183
+ group: "Cloudflare",
184
+ summary: "Recent deployments of a Pages project (url, environment, status, branch, commit).",
185
+ tool: "cloudflare_pages_deployments",
186
+ positionals: [{ name: "project", required: true, description: "Pages project name." }],
187
+ flags: [ACCOUNT_FLAG, { name: "page", type: "number", description: "Page number." }],
188
+ async run(ctx, inv) {
189
+ const project = requirePositional(inv, 0, "A Pages project name", "scribeclock cf deployments scribeclock");
190
+ const data = await ctx.call("cloudflare_pages_deployments", { project, account_id: str(inv.flags.account), page: num(inv.flags.page) });
191
+ return emit(ctx, data, () => renderDeployments(ctx, (data.deployments ?? data.result ?? [])));
192
+ },
193
+ });
194
+ function renderDeployments(ctx, deployments) {
195
+ ctx.out.table(["when", "env", "status", "branch", "commit", "url"], deployments.map((d) => {
196
+ const status = String(d.latest_stage?.status ?? d.status ?? d.stage ?? "");
197
+ const meta = d.deployment_trigger?.metadata;
198
+ return [fmtDate(String(d.created_on ?? d.created_at ?? ""), { time: true }), d.environment, status === "success" ? ctx.out.green(status) : /fail|error/.test(status) ? ctx.out.red(status) : status, d.branch ?? meta?.branch, String(d.commit ?? meta?.commit_hash ?? "").slice(0, 8), d.url];
199
+ }));
200
+ }
201
+ export const cfDeployStatus = cmd({
202
+ path: "cf deploy-status",
203
+ group: "Cloudflare",
204
+ summary: "The latest production deployment of a Pages project and whether it succeeded (exit 1 when it failed).",
205
+ tool: "cloudflare_pages_deployments",
206
+ positionals: [{ name: "project", required: true, description: "Pages project name." }],
207
+ flags: [ACCOUNT_FLAG],
208
+ async run(ctx, inv) {
209
+ const project = requirePositional(inv, 0, "A Pages project name", "scribeclock cf deploy-status scribeclock");
210
+ const data = await ctx.call("cloudflare_pages_deployments", { project, account_id: str(inv.flags.account) });
211
+ const deployments = (data.deployments ?? data.result ?? []);
212
+ const prod = deployments.find((d) => d.environment === "production") ?? deployments[0];
213
+ if (!prod)
214
+ throw usageError(`No deployments found for ${project}.`);
215
+ const status = String(prod.latest_stage?.status ?? prod.status ?? "");
216
+ const failed = /fail|error|cancel/i.test(status);
217
+ if (ctx.flags.json)
218
+ ctx.out.jsonOut({ ok: !failed, project, status, deployment: prod });
219
+ else {
220
+ ctx.out.log(`${failed ? ctx.out.red("✗") : status === "success" ? ctx.out.green("✓") : ctx.out.yellow("…")} ${project} ${String(prod.environment ?? "")}: ${status} · ${fmtDate(String(prod.created_on ?? ""), { time: true })} · ${String(prod.url ?? "")}`);
221
+ renderDeployments(ctx, deployments.slice(0, 5));
222
+ }
223
+ return failed ? EXIT.ERROR : EXIT.OK;
224
+ },
225
+ });
226
+ export const cfWorkers = cmd({
227
+ path: "cf workers",
228
+ group: "Cloudflare",
229
+ summary: "Workers scripts in the account.",
230
+ tool: "cloudflare_workers",
231
+ flags: [ACCOUNT_FLAG],
232
+ async run(ctx, inv) {
233
+ const data = await ctx.call("cloudflare_workers", { account_id: str(inv.flags.account) });
234
+ return emit(ctx, data, () => {
235
+ const workers = (data.workers ?? data.scripts ?? data.result ?? []);
236
+ ctx.out.table(["name", "modified", "compat date", "usage model"], workers.map((w) => [w.id ?? w.name, fmtDate(String(w.modified_on ?? ""), { time: true }), w.compatibility_date, w.usage_model]));
237
+ });
238
+ },
239
+ });
240
+ export const cfPurge = cmd({
241
+ path: "cf purge",
242
+ group: "Cloudflare",
243
+ summary: "Purge a zone's cache (everything, or up to 30 URLs). Live; asks first.",
244
+ tool: "cloudflare_purge_cache",
245
+ positionals: [{ name: "zone", required: true }, { name: "urls", variadic: true, description: "Specific URLs to purge (omit to purge everything)." }],
246
+ flags: [{ name: "url", alias: "u", type: "array", description: "URLs to purge (alternative to positionals)." }],
247
+ examples: ["scribeclock cf purge scribeclock.com", "scribeclock cf purge scribeclock.com https://scribeclock.com/ https://scribeclock.com/pricing"],
248
+ async run(ctx, inv) {
249
+ const zone = requirePositional(inv, 0, "A zone", "scribeclock cf purge example.com");
250
+ const files = [...inv.positionals.slice(1), ...(list(inv.flags.url) ?? [])];
251
+ return guardedEmit(ctx, "cloudflare_purge_cache", files.length ? { zone, files } : { zone, purge_everything: true }, () => ctx.out.log(`${ctx.out.green("✓")} Purged ${files.length ? `${files.length} URL${files.length === 1 ? "" : "s"}` : "everything"} in ${zone}.`));
252
+ },
253
+ });
254
+ export const cfRequest = cmd({
255
+ path: "cf request",
256
+ group: "Cloudflare",
257
+ summary: "Any other Cloudflare API call: cf request GET /zones/{zone_id}/settings/ssl [--body '{json}'].",
258
+ tool: "cloudflare_request",
259
+ positionals: [{ name: "method", required: true, description: "GET, HEAD, POST, PUT, PATCH, DELETE" }, { name: "path", required: true, description: "/zones/… or /accounts/{account}/…" }],
260
+ flags: [ACCOUNT_FLAG, { name: "body", alias: "b", type: "string", description: "JSON body for writes (@file reads a file)." }],
261
+ async run(ctx, inv) {
262
+ const method = requirePositional(inv, 0, "A method", "scribeclock cf request GET /zones").toUpperCase();
263
+ const path = requirePositional(inv, 1, "A path", "scribeclock cf request GET /zones");
264
+ const raw = str(inv.flags.body);
265
+ const body = raw ? JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw) : undefined;
266
+ const { renderResult } = await import("../render.js");
267
+ return guardedEmit(ctx, "cloudflare_request", { method, path, body, account_id: str(inv.flags.account) }, (data) => renderResult(ctx.out, data));
268
+ },
269
+ });
270
+ export const CF_COMMANDS = [cfAccounts, cfZones, cfDns, cfDnsSet, cfDnsRm, cfDnsDiff, cfPages, cfDeployments, cfDeployStatus, cfWorkers, cfPurge, cfRequest];
@@ -0,0 +1,189 @@
1
+ import { usageError } from "../cli/errors.js";
2
+ import { changedFiles, currentBranch, detectTaskKey, gitState, lastCommit, repoRef } from "../git.js";
3
+ import { EXIT } from "../types.js";
4
+ import { fmtClock, fmtDate, fmtDuration, parseWhen } from "../util/format.js";
5
+ import { cmd, emit, str, taskKey } from "./_shared.js";
6
+ const DEFAULT_LONG_SHIFT_HOURS = 10;
7
+ function longShiftHours(ctx) {
8
+ const fromEnv = Number(ctx.env.SCRIBECLOCK_LONG_SHIFT_HOURS);
9
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
10
+ return fromEnv;
11
+ return ctx.config.read().long_shift_hours ?? DEFAULT_LONG_SHIFT_HOURS;
12
+ }
13
+ export function renderClockStatus(ctx, data) {
14
+ const out = ctx.out;
15
+ if (data.clocked_in === true) {
16
+ const elapsed = Number(data.elapsed_seconds ?? 0);
17
+ const hours = elapsed / 3600;
18
+ out.log(`${out.green("●")} Clocked in ${out.bold(fmtClock(elapsed))} ${out.dim(`since ${fmtDate(String(data.started_at), { time: true })}`)}${data.project_name ? ` · ${String(data.project_name)}` : ""}${data.task_key ? ` · ${String(data.task_key)}` : ""}${data.organization_name ? out.dim(` · ${String(data.organization_name)}`) : ""}`);
19
+ const limit = longShiftHours(ctx);
20
+ if (hours >= limit)
21
+ out.warn(`This shift is ${fmtDuration(elapsed)} long — did you forget to clock out? Fix it with: scribeclock clock out --at "17:30"`);
22
+ }
23
+ else if (data.status === "paused") {
24
+ out.log(`${out.yellow("◐")} Paused ${out.dim(`since ${fmtDate(String(data.paused_at ?? ""), { time: true })}`)} · ${fmtDuration(Number(data.banked_seconds ?? 0))} banked. \`clock in\` resumes, \`clock out\` ends it.`);
25
+ }
26
+ else {
27
+ out.log(`${out.dim("○")} Not clocked in.`);
28
+ }
29
+ }
30
+ export const clockStatus = cmd({
31
+ path: "clock status",
32
+ group: "Time",
33
+ summary: "Am I clocked in? Running timer, project and a long-shift warning.",
34
+ tool: "clock_status",
35
+ async run(ctx) {
36
+ const data = await ctx.call("clock_status", {}, { skipOrg: true });
37
+ return emit(ctx, data, () => renderClockStatus(ctx, data));
38
+ },
39
+ });
40
+ export const clockIn = cmd({
41
+ path: "clock in",
42
+ group: "Time",
43
+ summary: "Start a shift (optionally under a project / task; the task is detected from the git branch).",
44
+ description: "Clocks you in exactly like the Clock in button. In a git repository the task key in the branch name or last commit (feat/123-…, #123) is used unless --no-auto; pass --task to choose one and --project to clock in under a project.",
45
+ tool: "clock_in",
46
+ flags: [
47
+ { name: "project", alias: "p", type: "string", placeholder: "<id|name>", description: "Project to clock in under (default General, or the task's project)." },
48
+ { name: "task", alias: "t", type: "string", placeholder: "<#123|auto>", description: "Task to record the shift against (auto = detect from git)." },
49
+ { name: "no-auto", type: "boolean", description: "Do not detect the task from git." },
50
+ ],
51
+ examples: ["scribeclock clock in", "scribeclock clock in --task #731", "scribeclock clock in --project 'Kisa Print'"],
52
+ async run(ctx, inv) {
53
+ let task = str(inv.flags.task);
54
+ if (!task && !inv.flags["no-auto"]) {
55
+ const detected = detectTaskKey(ctx.cwd);
56
+ if (detected) {
57
+ task = detected.key;
58
+ ctx.out.info(ctx.out.dim(`Task ${task} detected from the ${detected.source}.`));
59
+ }
60
+ }
61
+ else if (task)
62
+ task = taskKey(ctx, task);
63
+ const data = await ctx.call("clock_in", { project: str(inv.flags.project) ?? ctx.repo.config.project, task_id: task });
64
+ return emit(ctx, data, () => {
65
+ if (data.already_clocked_in)
66
+ ctx.out.log(`${ctx.out.yellow("●")} Already clocked in since ${fmtDate(String(data.started_at), { time: true })}${data.project_name ? ` · ${String(data.project_name)}` : ""}.`);
67
+ else
68
+ ctx.out.log(`${ctx.out.green("●")} Clocked in${data.resumed ? " (resumed)" : ""} at ${fmtDate(String(data.started_at), { time: true })}${data.project_name ? ` · ${String(data.project_name)}` : ""}${data.task_key ? ` · ${String(data.task_key)}` : ""}${data.organization_name ? ctx.out.dim(` · ${String(data.organization_name)}`) : ""}`);
69
+ if (data.task_ignored)
70
+ ctx.out.warn(String(data.task_ignored));
71
+ });
72
+ },
73
+ });
74
+ export const clockOut = cmd({
75
+ path: "clock out",
76
+ group: "Time",
77
+ summary: 'End the shift; --at "17:30" corrects a forgotten clock-out; --handoff leaves a team note.',
78
+ tool: "clock_out",
79
+ flags: [
80
+ { name: "summary", alias: "m", type: "string", description: "What was worked on (saved on the shift)." },
81
+ { name: "at", type: "string", placeholder: "<HH:MM|ISO>", description: "Clock out as of this time instead of now (e.g. 17:30, yesterday 18:00 as ISO)." },
82
+ { name: "handoff", type: "boolean", description: "Also record a handoff note for the repository you are in (branch, last commit, changed files, git state)." },
83
+ { name: "unfinished", alias: "u", type: "array", description: "Unfinished items for the handoff." },
84
+ { name: "next", type: "array", description: "Next steps for the handoff." },
85
+ ],
86
+ examples: ['scribeclock clock out -m "Kisa Print DNS and deploy"', 'scribeclock clock out --at 17:30', 'scribeclock clock out --handoff -u "SSL still pending" --next "verify cert" --next "smoke test checkout"'],
87
+ async run(ctx, inv) {
88
+ const args = { summary: str(inv.flags.summary) };
89
+ if (inv.flags.at) {
90
+ const at = parseWhen(String(inv.flags.at), ctx.now());
91
+ if (!at)
92
+ throw usageError(`Could not understand --at "${String(inv.flags.at)}".`, "Use HH:MM (today), or an ISO timestamp like 2026-10-21T17:30.");
93
+ if (at.getTime() > ctx.now().getTime() + 60_000)
94
+ throw usageError("--at is in the future.");
95
+ args.at = at.toISOString();
96
+ }
97
+ if (inv.flags.handoff) {
98
+ const repo = repoRef(ctx.cwd);
99
+ if (!repo)
100
+ throw usageError("--handoff needs a git repository (run it inside one).");
101
+ const commit = lastCommit(ctx.cwd);
102
+ args.handoff = {
103
+ repo,
104
+ summary: str(inv.flags.summary),
105
+ unfinished: Array.isArray(inv.flags.unfinished) ? inv.flags.unfinished : undefined,
106
+ next_steps: Array.isArray(inv.flags.next) ? inv.flags.next : undefined,
107
+ files_changed: changedFiles(ctx.cwd),
108
+ branch: currentBranch(ctx.cwd) ?? undefined,
109
+ last_commit: commit ? { sha: commit.sha, message: commit.message.slice(0, 300) } : undefined,
110
+ git: gitState(ctx.cwd),
111
+ task_id: detectTaskKey(ctx.cwd)?.key,
112
+ };
113
+ if (!args.summary)
114
+ throw usageError("--handoff needs --summary (what was done, for the person who continues).");
115
+ }
116
+ const data = await ctx.call("clock_out", args, { skipOrg: true });
117
+ return emit(ctx, data, () => {
118
+ if (data.was_clocked_in === false)
119
+ ctx.out.log(`${ctx.out.dim("○")} You were not clocked in; nothing changed.`);
120
+ else if (data.paused_session_left)
121
+ ctx.out.log(`${ctx.out.yellow("◐")} You were paused, not running. End the paused session from the app, or clock in again to resume.`);
122
+ else if (data.discarded_or_already_closed)
123
+ ctx.out.log(`${ctx.out.dim("○")} Shift closed (under one minute: not saved).`);
124
+ else
125
+ ctx.out.log(`${ctx.out.green("✓")} Clocked out${args.at ? ` as of ${fmtDate(String(data.ended_at ?? args.at), { time: true })}` : ""} · ${ctx.out.bold(fmtDuration(Number(data.duration_seconds ?? 0)))}${data.project_name ? ` · ${String(data.project_name)}` : ""}`);
126
+ if (data.handoff && typeof data.handoff === "object")
127
+ ctx.out.info(ctx.out.dim("Handoff note recorded for the team."));
128
+ });
129
+ },
130
+ });
131
+ /** Shell prompt segment: fast, cached for 60 s, silent on failure. */
132
+ export const promptCommand = cmd({
133
+ path: "prompt",
134
+ group: "Time",
135
+ summary: "One-line prompt segment for PS1 / starship: running timer + task, cached 60 s.",
136
+ description: `Prints something like "⏱ 2h13m #731" when clocked in, nothing when not. Results are cached for 60 seconds so your prompt stays fast. Exits 0 always.
137
+
138
+ bash: PS1='$(scribeclock prompt) \\w \\$ '
139
+ zsh: setopt PROMPT_SUBST; PROMPT='$(scribeclock prompt) %~ %# '
140
+ starship: [custom.scribeclock] command = "scribeclock prompt" when = true format = "[$output]($style) "`,
141
+ tool: "clock_status",
142
+ flags: [
143
+ { name: "format", alias: "f", type: "string", placeholder: "<template>", description: "Template with {elapsed} {clock} {task} {project} (default \"⏱ {elapsed} {task}\")." },
144
+ { name: "ttl", type: "number", description: "Cache seconds (default 60)." },
145
+ ],
146
+ async run(ctx, inv) {
147
+ const ttl = (Number(inv.flags.ttl) || 60) * 1000;
148
+ const cache = ctx.config.read().prompt_cache;
149
+ const format = str(inv.flags.format) ?? "⏱ {elapsed} {task}";
150
+ const render = (d) => {
151
+ if (d.clocked_in !== true)
152
+ return "";
153
+ const elapsed = Number(d.elapsed_seconds ?? 0);
154
+ return format
155
+ .replace("{elapsed}", fmtDuration(elapsed, { compact: true }))
156
+ .replace("{clock}", fmtClock(elapsed))
157
+ .replace("{task}", String(d.task_key ?? ""))
158
+ .replace("{project}", String(d.project_name ?? ""))
159
+ .replace(/\s+$/, "");
160
+ };
161
+ if (cache && ctx.now().getTime() - new Date(cache.at).getTime() < ttl) {
162
+ try {
163
+ const d = JSON.parse(cache.text);
164
+ if (d.clocked_in && d.started_at)
165
+ d.elapsed_seconds = Math.floor((ctx.now().getTime() - new Date(String(d.started_at)).getTime()) / 1000) + Number(d.accumulated_seconds ?? 0);
166
+ const text = render(d);
167
+ if (text)
168
+ ctx.out.log(text);
169
+ return EXIT.OK;
170
+ }
171
+ catch {
172
+ /* refetch */
173
+ }
174
+ }
175
+ try {
176
+ const data = await ctx.call("clock_status", {}, { skipOrg: true });
177
+ const accumulated = data.clocked_in ? Number(data.elapsed_seconds ?? 0) - Math.floor((ctx.now().getTime() - new Date(String(data.started_at)).getTime()) / 1000) : 0;
178
+ ctx.config.write({ prompt_cache: { at: ctx.now().toISOString(), text: JSON.stringify({ ...data, accumulated_seconds: Math.max(0, accumulated) }) } });
179
+ const text = render(data);
180
+ if (text)
181
+ ctx.out.log(text);
182
+ }
183
+ catch {
184
+ /* a prompt must never fail */
185
+ }
186
+ return EXIT.OK;
187
+ },
188
+ });
189
+ export const CLOCK_COMMANDS = [clockStatus, clockIn, clockOut, promptCommand];