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,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, extname } from "node:path";
4
+ import { CliError, usageError } from "../cli/errors.js";
5
+ import { fmtDate } from "../util/format.js";
6
+ import { PROJECT_FLAG, bool, cmd, emit, guardedEmit, num, personName, projectArg, requirePositional, str } from "./_shared.js";
7
+ export const filesLs = cmd({
8
+ path: "files ls",
9
+ group: "Files",
10
+ summary: "List files: a project's Drive/files (--project) or the organization's Files tab (folder, path or search).",
11
+ tool: ["list_project_files", "list_organization_files"],
12
+ positionals: [{ name: "path", description: "Files-tab folder path such as Documents/Specs (organization mode)." }],
13
+ flags: [
14
+ PROJECT_FLAG,
15
+ { name: "folder", type: "string", placeholder: "<id>", description: "Files-tab folder id." },
16
+ { name: "query", alias: "q", type: "string", description: "Search the whole organization by name." },
17
+ { name: "limit", alias: "n", type: "number", description: "Max items (default 50)." },
18
+ ],
19
+ examples: ["scribeclock files ls --project 'Scribe Clock'", "scribeclock files ls Documents/Specs", "scribeclock files ls -q invoice"],
20
+ async run(ctx, inv) {
21
+ const project = str(inv.flags.project);
22
+ if (project || (!inv.positionals[0] && !inv.flags.folder && !inv.flags.query && ctx.repo.config.project)) {
23
+ const project_id = project ?? ctx.repo.config.project;
24
+ const data = await ctx.call("list_project_files", { project_id });
25
+ return emit(ctx, data, () => {
26
+ const files = (data.files ?? data.items ?? []);
27
+ ctx.out.log(ctx.out.bold(`${String(data.project_name ?? project_id)} — ${files.length} file${files.length === 1 ? "" : "s"}`) + ctx.out.dim(` (${data.google_drive_connected || data.has_google_drive ? "Google Drive linked" : "ScribeClock drive"}${data.can_write === false ? ", read-only" : ""})`));
28
+ ctx.out.table(["id", "title", "type", "storage", "link"], files.map((f) => [f.id ?? f.drive_file_id, f.title ?? f.name, f.mime_type, f.storage, f.web_view_link ?? f.url]));
29
+ });
30
+ }
31
+ const data = await ctx.call("list_organization_files", { folder_path: inv.positionals[0], folder_id: str(inv.flags.folder), query: str(inv.flags.query), limit: num(inv.flags.limit) });
32
+ return emit(ctx, data, () => {
33
+ const files = (data.files ?? []);
34
+ ctx.out.table(["id", "name", "kind", "size", "uploaded by", "updated", "staff only"], files.map((f) => [f.id, f.is_folder ? `${String(f.name)}/` : f.name, f.is_folder ? "folder" : (f.mime_type ?? "file"), f.size_bytes ?? f.size ?? "", personName(f.uploaded_by), fmtDate(String(f.updated_at ?? "")), f.staff_only ? "yes" : ""]));
35
+ });
36
+ },
37
+ });
38
+ function textBlocks(content) {
39
+ return content
40
+ .filter((c) => c.type === "text")
41
+ .map((c) => c.text)
42
+ .join("\n");
43
+ }
44
+ async function downloadChunks(ctx, tool, baseArgs, out) {
45
+ const parts = [];
46
+ let offset = 0;
47
+ let expected;
48
+ for (let guard = 0; guard < 1000; guard++) {
49
+ const page = await ctx.call(tool, { ...baseArgs, byte_offset: offset, byte_length: 2 * 1024 * 1024 });
50
+ if (typeof page.sha256 === "string")
51
+ expected = page.sha256;
52
+ const b64 = typeof page.content_base64 === "string" ? page.content_base64 : "";
53
+ if (b64)
54
+ parts.push(Buffer.from(b64, "base64"));
55
+ const next = page.next_byte_offset;
56
+ if (next === null || next === undefined || Number(next) <= offset)
57
+ break;
58
+ offset = Number(next);
59
+ ctx.out.info(ctx.out.dim(`…${Math.round(offset / 1024)} KB`));
60
+ }
61
+ const buf = Buffer.concat(parts);
62
+ writeFileSync(out, buf);
63
+ const actual = createHash("sha256").update(buf).digest("hex");
64
+ return { bytes: buf.length, sha256: expected, ok: !expected || expected === actual };
65
+ }
66
+ export const filesGet = cmd({
67
+ path: "files get",
68
+ group: "Files",
69
+ summary: "Read a file as text (default) or download it with --out; big files stream in verified chunks.",
70
+ tool: ["read_project_file", "read_organization_file"],
71
+ positionals: [{ name: "file", required: true, description: "File id / title (project) or Files-tab item id (organization)." }],
72
+ flags: [
73
+ PROJECT_FLAG,
74
+ { name: "out", alias: "o", type: "string", placeholder: "<path>", description: "Save the original bytes to this path (chunked download for big files)." },
75
+ { name: "max-chars", type: "number", description: "Text length per call (default 100000)." },
76
+ ],
77
+ examples: ["scribeclock files get <id>", "scribeclock files get 'Architecture notes' --project 'Scribe Clock'", "scribeclock files get <id> --out ./spec.pdf"],
78
+ async run(ctx, inv) {
79
+ const file = requirePositional(inv, 0, "A file id", "scribeclock files get <id>");
80
+ const project_id = str(inv.flags.project) ?? (/^[0-9a-f-]{36}$/i.test(file) ? undefined : ctx.repo.config.project);
81
+ if (project_id) {
82
+ const data = await ctx.call("read_project_file", { project_id, file_id: file, max_chars: num(inv.flags["max-chars"]) });
83
+ if (inv.flags.out) {
84
+ const text = String(data.text ?? data.content ?? "");
85
+ writeFileSync(String(inv.flags.out), text);
86
+ ctx.out.log(`Saved ${text.length} characters to ${String(inv.flags.out)}.`);
87
+ return 0;
88
+ }
89
+ return emit(ctx, data, () => ctx.out.log(String(data.text ?? data.content ?? JSON.stringify(data, null, 2))));
90
+ }
91
+ if (inv.flags.out) {
92
+ const res = await downloadChunks(ctx, "read_organization_file", { file_id: file }, String(inv.flags.out));
93
+ if (!res.ok)
94
+ throw new CliError(`Checksum mismatch after download (expected sha256 ${res.sha256}).`, { id: "checksum_mismatch" });
95
+ if (ctx.flags.json)
96
+ ctx.out.jsonOut({ ok: true, path: inv.flags.out, bytes: res.bytes, sha256: res.sha256 });
97
+ else
98
+ ctx.out.log(`${ctx.out.green("✓")} Saved ${res.bytes} bytes to ${String(inv.flags.out)}${res.sha256 ? ctx.out.dim(` (sha256 verified)`) : ""}.`);
99
+ return 0;
100
+ }
101
+ const outcome = await ctx.callRaw("read_organization_file", { file_id: file, max_chars: num(inv.flags["max-chars"]) });
102
+ if (!outcome.ok) {
103
+ const { toolError } = await import("../context.js");
104
+ throw toolError("read_organization_file", outcome);
105
+ }
106
+ const data = outcome.data;
107
+ if (ctx.flags.json) {
108
+ ctx.out.jsonOut(data);
109
+ return 0;
110
+ }
111
+ if (typeof data.text === "string") {
112
+ ctx.out.log(data.text);
113
+ if (data.next_text_offset)
114
+ ctx.out.info(ctx.out.dim(`… more text: --max-chars or the raw tool with --text-offset ${String(data.next_text_offset)}`));
115
+ }
116
+ else {
117
+ const extra = textBlocks(outcome.content.filter((c) => c.type === "text" && !c.text.startsWith("{")));
118
+ if (extra)
119
+ ctx.out.log(extra);
120
+ ctx.out.log(ctx.out.dim(`${String(data.mime_type ?? "binary")}, ${String(data.total_bytes ?? "?")} bytes${data.rendered ? ` (${String(data.rendered)})` : ""}. Download it with --out <path>.`));
121
+ }
122
+ return 0;
123
+ },
124
+ });
125
+ const TEXT_EXT = new Set([".md", ".markdown", ".txt", ".csv", ".json", ".yaml", ".yml", ".html", ".htm", ".ts", ".js", ".py", ".sql", ".xml", ".toml", ".ini", ".env", ".log"]);
126
+ export const filesPut = cmd({
127
+ path: "files put",
128
+ group: "Files",
129
+ summary: "Upload a file to a project (--project) or write a text/Markdown document into the Files tab.",
130
+ tool: ["upload_project_file", "create_document"],
131
+ positionals: [{ name: "path", required: true, description: "Local file to upload." }],
132
+ flags: [
133
+ PROJECT_FLAG,
134
+ { name: "name", type: "string", description: "Stored file name (default: the local name)." },
135
+ { name: "folder", type: "string", placeholder: "<id|path>", description: "Drive folder id (project) or Files-tab folder path (organization)." },
136
+ { name: "google-doc", type: "boolean", description: "Convert text to a native Google Doc (project with Drive)." },
137
+ { name: "restricted", type: "boolean", description: "Project file visibility: restricted instead of all assignees." },
138
+ { name: "staff-only", type: "boolean", description: "Files tab: hide from non-staff members." },
139
+ ],
140
+ examples: ["scribeclock files put ./notes.md --project 'Scribe Clock'", "scribeclock files put ./spec.md --folder Documents/Specs"],
141
+ async run(ctx, inv) {
142
+ const path = requirePositional(inv, 0, "A file path", "scribeclock files put ./notes.md --project X");
143
+ if (!existsSync(path))
144
+ throw usageError(`No such file: ${path}`);
145
+ const bytes = readFileSync(path);
146
+ if (bytes.length > 5 * 1024 * 1024)
147
+ throw usageError("Files are limited to 5 MB here.", "Attach bigger files to a task with `scribeclock tasks attach` (up to 25 MB).");
148
+ const filename = str(inv.flags.name) ?? basename(path);
149
+ const ext = extname(filename).toLowerCase();
150
+ const isText = TEXT_EXT.has(ext);
151
+ const project_id = str(inv.flags.project) ?? ctx.repo.config.project;
152
+ if (project_id) {
153
+ const data = await ctx.call("upload_project_file", { project_id, filename, ...(isText ? { content: bytes.toString("utf8") } : { content_base64: bytes.toString("base64") }), folder_id: str(inv.flags.folder), as_google_doc: bool(inv.flags["google-doc"]), visibility: inv.flags.restricted ? "restricted" : undefined });
154
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Uploaded ${filename} (${(bytes.length / 1024).toFixed(0)} KB) to ${String(data.project_name ?? project_id)}${data.web_view_link || data.url ? ctx.out.dim(` ${String(data.web_view_link ?? data.url)}`) : ""}`));
155
+ }
156
+ if (!isText && ext !== ".docx")
157
+ throw usageError("Without --project only text documents (.md, .txt, .html, .csv, .json…) or .docx can be written to the Files tab.", "Pass --project <id|name> to upload any file into a project.");
158
+ const format = ext === ".docx" ? "docx" : ext === ".html" || ext === ".htm" ? "html" : ext === ".md" || ext === ".markdown" ? "markdown" : "text";
159
+ const data = await ctx.call("create_document", { title: filename.replace(/\.[^.]+$/, ""), destination: "files", format, ...(ext === ".docx" ? { content_base64: bytes.toString("base64") } : { content: bytes.toString("utf8") }), folder_path: str(inv.flags.folder), staff_only: bool(inv.flags["staff-only"]) });
160
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Saved ${filename} to the Files tab${data.url ? ctx.out.dim(` ${String(data.url)}`) : ""}`));
161
+ },
162
+ });
163
+ export const filesRm = cmd({
164
+ path: "files rm",
165
+ group: "Files",
166
+ summary: "Permanently delete a Files-tab item (asks; --dry-run shows what would go).",
167
+ tool: "delete_organization_file",
168
+ positionals: [{ name: "id", required: true, description: "Files-tab item id." }],
169
+ async run(ctx, inv) {
170
+ const file_id = requirePositional(inv, 0, "A file id", "scribeclock files rm <id>");
171
+ return guardedEmit(ctx, "delete_organization_file", { file_id }, (data) => ctx.out.log(`${ctx.out.green("✓")} Deleted ${String(data.name ?? file_id)}.`));
172
+ },
173
+ });
174
+ export const docsNew = cmd({
175
+ path: "docs new",
176
+ group: "Meetings & docs",
177
+ summary: "Create a document from Markdown: docs new --from spec.md [--project P] [--format docx|google_doc|markdown|html].",
178
+ tool: "create_document",
179
+ flags: [
180
+ { name: "from", alias: "f", type: "string", placeholder: "<file.md|->", description: "Markdown source (- for stdin).", required: true },
181
+ { name: "title", alias: "t", type: "string", description: "Document title (default: first heading or the file name)." },
182
+ PROJECT_FLAG,
183
+ { name: "format", type: "string", choices: ["google_doc", "docx", "markdown", "html", "text"], description: "Output format (default: Google Doc in a project with Drive, else Markdown)." },
184
+ { name: "dest", type: "string", choices: ["project_drive", "files"], description: "Where it goes (default: project drive when a project has Drive, else Files tab)." },
185
+ { name: "folder", type: "string", placeholder: "<path|id>", description: "Files-tab folder path (created if missing) or Drive folder id." },
186
+ { name: "task", type: "string", placeholder: "<#123>", description: "Also comment the link on this task." },
187
+ { name: "staff-only", type: "boolean", description: "Files tab: staff only." },
188
+ ],
189
+ examples: ["scribeclock docs new --from release-notes.md --project 'Scribe Clock' --format docx", "cat notes.md | scribeclock docs new --from - --title 'Standup notes' --folder Documents/Standups"],
190
+ async run(ctx, inv) {
191
+ const from = String(inv.flags.from);
192
+ const content = from === "-" ? readFileSync(0, "utf8") : readFileSync(from, "utf8");
193
+ const heading = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
194
+ const title = str(inv.flags.title) ?? heading ?? (from === "-" ? "Document" : basename(from).replace(/\.[^.]+$/, ""));
195
+ const folder = str(inv.flags.folder);
196
+ const data = await ctx.call("create_document", {
197
+ title,
198
+ content,
199
+ project_id: projectArg(ctx, inv),
200
+ format: str(inv.flags.format),
201
+ destination: str(inv.flags.dest),
202
+ folder_path: folder && !/^[0-9a-f-]{36}$/i.test(folder) ? folder : undefined,
203
+ folder_id: folder && /^[0-9a-f-]{36}$/i.test(folder) ? folder : undefined,
204
+ task_id: str(inv.flags.task),
205
+ staff_only: bool(inv.flags["staff-only"]),
206
+ });
207
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Created "${title}"${data.format ? ` (${String(data.format)})` : ""}${data.url || data.web_view_link ? ctx.out.dim(` ${String(data.url ?? data.web_view_link)}`) : ""}`));
208
+ },
209
+ });
210
+ export const FILE_COMMANDS = [filesLs, filesGet, filesPut, filesRm, docsNew];
@@ -0,0 +1,208 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { CliError, usageError } from "../cli/errors.js";
3
+ import { changedFiles, currentBranch, detectTaskKey, git, gitState, githubSlug, hooksPath, lastCommit, remoteUrl, repoRef, tagDate } from "../git.js";
4
+ import { EXIT } from "../types.js";
5
+ import { fmtDate, parseWhen, slugify } from "../util/format.js";
6
+ import { PROJECT_FLAG, bool, cmd, emit, list, personName, projectIdArg, str, taskKey } from "./_shared.js";
7
+ /** feat/123-short-title — the prefix comes from .scribeclock.json → branch_prefix (default feat). */
8
+ export function branchNameFor(ctx, task, prefix) {
9
+ const p = prefix ?? ctx.repo.config.branch_prefix ?? "feat";
10
+ const num = task.key.replace(/^#/, "");
11
+ const slug = slugify(task.title, 40);
12
+ return `${p}/${num}${slug ? `-${slug}` : ""}`;
13
+ }
14
+ /** This repository refuses branches (hooks in .githooks, CLAUDE.md "main only"). */
15
+ export function repoForbidsBranches(ctx) {
16
+ const hooks = hooksPath(ctx.cwd);
17
+ if (hooks && /githooks/.test(hooks)) {
18
+ const check = git(["config", "--get", "scribeclock.allowBranches"], ctx.cwd);
19
+ return check !== "true";
20
+ }
21
+ return false;
22
+ }
23
+ export const branchCommand = cmd({
24
+ path: "branch",
25
+ group: "Git",
26
+ summary: "Print (or, with --create, create) a conventionally named branch for a task: feat/123-short-title.",
27
+ description: "Prints the branch name a task would get. Branch creation is opt-in (--create) and off by default: repositories that work on main only (git hooks under .githooks) refuse it unless `git config scribeclock.allowBranches true`.",
28
+ tool: "get_task",
29
+ positionals: [{ name: "task", required: true, description: "Task key, uuid or auto." }],
30
+ flags: [
31
+ { name: "create", alias: "c", type: "boolean", description: "Create and switch to the branch (opt-in)." },
32
+ { name: "prefix", type: "string", description: "Branch prefix (default: .scribeclock.json → branch_prefix, else feat)." },
33
+ ],
34
+ examples: ["scribeclock branch #731", "scribeclock branch 731 --create --prefix fix"],
35
+ async run(ctx, inv) {
36
+ const task_id = taskKey(ctx, inv.positionals[0]);
37
+ const data = await ctx.call("get_task", { task_id, comment_limit: 0 });
38
+ const t = (data.task ?? data);
39
+ const name = branchNameFor(ctx, { key: String(t.key ?? task_id), title: String(t.title ?? "") }, str(inv.flags.prefix));
40
+ if (!inv.flags.create)
41
+ return emit(ctx, { ok: true, branch: name, task: { key: t.key, title: t.title }, created: false }, () => ctx.out.log(name));
42
+ if (repoForbidsBranches(ctx))
43
+ throw new CliError("This repository works on main only (its git hooks refuse new branches).", { id: "branches_forbidden", hint: "Keep committing on main, or allow branches here with: git config scribeclock.allowBranches true" });
44
+ if (ctx.flags.dryRun)
45
+ return emit(ctx, { ok: true, branch: name, dry_run: true }, () => ctx.out.log(`${ctx.out.dim("dry run →")} git switch -c ${name}`));
46
+ const r = spawnSync("git", ["switch", "-c", name], { cwd: ctx.cwd, encoding: "utf8" });
47
+ if (r.status !== 0)
48
+ throw new CliError(`git switch -c ${name} failed: ${(r.stderr || r.stdout).trim()}`, { id: "git_failed" });
49
+ return emit(ctx, { ok: true, branch: name, created: true }, () => ctx.out.log(`${ctx.out.green("✓")} On new branch ${ctx.out.bold(name)}`));
50
+ },
51
+ });
52
+ export const commitCommand = cmd({
53
+ path: "commit",
54
+ group: "Git",
55
+ summary: 'git commit with the task key in the message, then post the commit as progress: commit -m "msg" [--task #12].',
56
+ description: "Runs `git commit` (staged changes; -a to stage tracked changes) with the task key appended as (#123), then reports progress on the task with the commit as evidence (skip with --no-report). The task defaults to the one in the branch name or last commit.",
57
+ tool: "report_task_progress",
58
+ flags: [
59
+ { name: "message", alias: "m", type: "string", description: "Commit message.", required: true },
60
+ { name: "task", alias: "t", type: "string", description: "Task key (default: detected from git)." },
61
+ { name: "all", alias: "a", type: "boolean", description: "Stage modified/deleted tracked files first (git commit -a)." },
62
+ { name: "no-report", type: "boolean", description: "Only commit; do not post progress." },
63
+ { name: "amend", type: "boolean", description: "Amend the previous commit." },
64
+ ],
65
+ examples: ['scribeclock commit -m "Wire Stripe webhook" --task #730', 'scribeclock commit -am "Fix DNS record" '],
66
+ async run(ctx, inv) {
67
+ const message = String(inv.flags.message);
68
+ const explicit = str(inv.flags.task);
69
+ const detected = explicit ? null : detectTaskKey(ctx.cwd);
70
+ const key = explicit ? taskKey(ctx, explicit) : detected?.key;
71
+ if (!key)
72
+ throw usageError("No task given and none detected from the branch or last commit.", "Pass --task #123.");
73
+ const full = new RegExp(`(^|[^0-9])${key.replace("#", "#")}(?![0-9])`).test(message) ? message : `${message} (${key})`;
74
+ const args = ["commit", "-m", full];
75
+ if (inv.flags.all)
76
+ args.splice(1, 0, "-a");
77
+ if (inv.flags.amend)
78
+ args.push("--amend");
79
+ if (ctx.flags.dryRun) {
80
+ ctx.out.log(`${ctx.out.dim("dry run →")} git ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
81
+ return EXIT.OK;
82
+ }
83
+ const r = spawnSync("git", args, { cwd: ctx.cwd, encoding: "utf8" });
84
+ if (r.status !== 0)
85
+ throw new CliError(`git commit failed: ${(r.stderr || r.stdout).trim()}`, { id: "git_failed", hint: "Stage your changes first (git add …) or pass -a." });
86
+ const commit = lastCommit(ctx.cwd);
87
+ ctx.out.info(`${ctx.out.green("✓")} ${commit?.sha.slice(0, 10) ?? ""} ${full}`);
88
+ if (inv.flags["no-report"])
89
+ return emit(ctx, { ok: true, commit, reported: false }, () => undefined);
90
+ const slug = githubSlug(remoteUrl(ctx.cwd));
91
+ const data = await ctx.call("report_task_progress", {
92
+ task_id: key,
93
+ outcome: "progress",
94
+ summary: `Committed: ${message}`,
95
+ evidence: commit ? [{ kind: "commit", title: full.slice(0, 80), detail: `${commit.sha.slice(0, 10)} ${full}`.slice(0, 300), url: slug ? `https://github.com/${slug}/commit/${commit.sha}` : undefined }] : [],
96
+ repo: repoRef(ctx.cwd) ?? undefined,
97
+ branch: currentBranch(ctx.cwd) ?? undefined,
98
+ files_changed: changedFiles(ctx.cwd),
99
+ last_commit: commit ? { sha: commit.sha, message: full.slice(0, 300) } : undefined,
100
+ git: gitState(ctx.cwd),
101
+ });
102
+ return emit(ctx, { ok: true, commit, reported: true, report: data }, () => ctx.out.log(`${ctx.out.green("✓")} Progress posted on ${key}.`));
103
+ },
104
+ });
105
+ export const prCommand = cmd({
106
+ path: "pr",
107
+ group: "Git",
108
+ summary: "Open a GitHub pull request for a task (title + task link + summary) and attach it to the task as evidence.",
109
+ description: "Needs the GitHub CLI (`gh`) and a pushed branch. The PR title is \"#123 Task title\", the body links the ScribeClock task; the PR url is then reported on the task (outcome=progress, pull_request evidence).",
110
+ tool: ["get_task", "report_task_progress"],
111
+ positionals: [{ name: "task", description: "Task key (default: detected from git)." }],
112
+ flags: [
113
+ { name: "base", alias: "B", type: "string", description: "Base branch (default: the repository default)." },
114
+ { name: "draft", type: "boolean", description: "Open as a draft." },
115
+ { name: "body", type: "string", description: "Extra body text (@file reads a file)." },
116
+ { name: "no-report", type: "boolean", description: "Do not post the PR on the task." },
117
+ ],
118
+ examples: ["scribeclock pr #731", "scribeclock pr --draft"],
119
+ async run(ctx, inv) {
120
+ const key = taskKey(ctx, inv.positionals[0]);
121
+ const branch = currentBranch(ctx.cwd);
122
+ if (!branch || branch === "main" || branch === "master")
123
+ throw usageError(`You are on ${branch ?? "no branch"}; a pull request needs a feature branch.`, "scribeclock branch #123 --create");
124
+ const which = spawnSync("gh", ["--version"], { encoding: "utf8" });
125
+ if (which.status !== 0)
126
+ throw new CliError("The GitHub CLI (gh) is not installed.", { id: "gh_missing", hint: "Install it from https://cli.github.com and run gh auth login." });
127
+ const data = await ctx.call("get_task", { task_id: key, comment_limit: 0 });
128
+ const t = (data.task ?? data);
129
+ const title = `${String(t.key ?? key)} ${String(t.title ?? "")}`.trim();
130
+ const extra = typeof inv.flags.body === "string" ? (inv.flags.body.startsWith("@") ? (await import("node:fs")).readFileSync(inv.flags.body.slice(1), "utf8") : inv.flags.body) : "";
131
+ const body = [`ScribeClock task: ${String(t.url ?? key)}`, "", typeof t.description === "string" ? t.description.split("\n").slice(0, 12).join("\n") : "", extra].filter(Boolean).join("\n\n");
132
+ const args = ["pr", "create", "--title", title, "--body", body];
133
+ if (inv.flags.base)
134
+ args.push("--base", String(inv.flags.base));
135
+ if (inv.flags.draft)
136
+ args.push("--draft");
137
+ if (ctx.flags.dryRun) {
138
+ ctx.out.log(`${ctx.out.dim("dry run →")} gh ${args.map((a) => (a.includes(" ") || a.includes("\n") ? JSON.stringify(a.slice(0, 60)) : a)).join(" ")}`);
139
+ return EXIT.OK;
140
+ }
141
+ const r = spawnSync("gh", args, { cwd: ctx.cwd, encoding: "utf8" });
142
+ if (r.status !== 0)
143
+ throw new CliError(`gh pr create failed: ${(r.stderr || r.stdout).trim()}`, { id: "gh_failed", hint: "Push the branch first (git push -u origin HEAD) and make sure gh is logged in." });
144
+ const url = (r.stdout.trim().split("\n").pop() ?? "").trim();
145
+ ctx.out.info(`${ctx.out.green("✓")} ${url}`);
146
+ if (inv.flags["no-report"])
147
+ return emit(ctx, { ok: true, url, reported: false }, () => undefined);
148
+ const report = await ctx.call("report_task_progress", { task_id: key, outcome: "progress", summary: `Opened pull request ${url}`, evidence: [{ kind: "pull_request", title, url }], repo: repoRef(ctx.cwd) ?? undefined, branch, git: gitState(ctx.cwd) });
149
+ return emit(ctx, { ok: true, url, reported: true, report }, () => ctx.out.log(`${ctx.out.green("✓")} Pull request attached to ${String(t.key ?? key)}.`));
150
+ },
151
+ });
152
+ export const changelogCommand = cmd({
153
+ path: "changelog",
154
+ group: "Git",
155
+ summary: "Release notes from completed tasks: changelog --since v1.2.0 (a tag, commit or date) [--project P].",
156
+ tool: "list_tasks",
157
+ flags: [
158
+ { name: "since", type: "string", placeholder: "<tag|ref|date>", description: "Tasks completed after this git tag/commit's date, or a date (YYYY-MM-DD, 14d).", required: true },
159
+ { name: "until", type: "string", placeholder: "<tag|ref|date>", description: "Upper bound." },
160
+ PROJECT_FLAG,
161
+ { name: "assignee", alias: "a", type: "string", description: "Only one person's tasks." },
162
+ { name: "heading", type: "string", description: "Title line (default: Changes since <since>)." },
163
+ { name: "group-by", type: "string", choices: ["project", "category", "none"], description: "Grouping (default project)." },
164
+ ],
165
+ examples: ["scribeclock changelog --since v1.61.0 > CHANGES.md", "scribeclock changelog --since 14d --project 'Scribe Clock' --group-by category"],
166
+ async run(ctx, inv) {
167
+ const resolveBound = (raw) => {
168
+ if (!raw)
169
+ return undefined;
170
+ const fromGit = tagDate(ctx.cwd, raw);
171
+ if (fromGit)
172
+ return new Date(fromGit);
173
+ const d = parseWhen(raw, ctx.now());
174
+ if (!d)
175
+ throw usageError(`"${raw}" is neither a git ref nor a date.`);
176
+ return d;
177
+ };
178
+ const since = resolveBound(String(inv.flags.since));
179
+ const until = resolveBound(str(inv.flags.until));
180
+ const data = await ctx.call("list_tasks", { status: "completed", updated_since: since.toISOString(), order_by: "updated", limit: 200, project_id: await projectIdArg(ctx, inv), assignee: str(inv.flags.assignee) });
181
+ const tasks = (data.tasks ?? []).filter((t) => t.completed_at && new Date(String(t.completed_at)) >= since && (!until || new Date(String(t.completed_at)) < until));
182
+ const groupBy = str(inv.flags["group-by"]) ?? "project";
183
+ const groups = new Map();
184
+ for (const t of tasks) {
185
+ const g = groupBy === "none" ? "" : groupBy === "category" ? String(t.category ?? "general") : (t.project?.name ?? "General");
186
+ groups.set(g, [...(groups.get(g) ?? []), t]);
187
+ }
188
+ const heading = str(inv.flags.heading) ?? `Changes since ${String(inv.flags.since)}${until ? ` until ${String(inv.flags.until)}` : ""}`;
189
+ const lines = [`# ${heading}`, "", `_${tasks.length} completed task${tasks.length === 1 ? "" : "s"}, ${fmtDate(since.toISOString())} → ${until ? fmtDate(until.toISOString()) : "now"}_`, ""];
190
+ for (const [g, items] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
191
+ if (g)
192
+ lines.push(`## ${g}`, "");
193
+ for (const t of items.sort((a, b) => String(a.completed_at).localeCompare(String(b.completed_at)))) {
194
+ lines.push(`- ${String(t.title)} ([${String(t.key)}](${String(t.url ?? "")}))${t.assignee ? ` — ${personName(t.assignee)}` : ""}`);
195
+ }
196
+ lines.push("");
197
+ }
198
+ if (ctx.flags.json)
199
+ ctx.out.jsonOut({ ok: true, since: since.toISOString(), until: until?.toISOString() ?? null, count: tasks.length, tasks, markdown: lines.join("\n") });
200
+ else
201
+ ctx.out.log(lines.join("\n").trimEnd());
202
+ return EXIT.OK;
203
+ },
204
+ });
205
+ export const GITFLOW_COMMANDS = [branchCommand, commitCommand, prCommand, changelogCommand];
206
+ void bool;
207
+ void list;
208
+ void JSON;
@@ -0,0 +1,35 @@
1
+ import { Registry } from "../cli/registry.js";
2
+ import { loginCommand, logoutCommand, whoamiCommand } from "./auth.js";
3
+ import { completionsCommand, helpCommand, versionCommand } from "./meta.js";
4
+ import { callCommand, refreshCommand, toolsCommand } from "./tools.js";
5
+ import { TASK_COMMANDS } from "./tasks.js";
6
+ import { CLOCK_COMMANDS } from "./clock.js";
7
+ import { PROJECT_COMMANDS } from "./projects.js";
8
+ import { MEETING_COMMANDS } from "./meetings.js";
9
+ import { FILE_COMMANDS } from "./files.js";
10
+ import { VAULT_COMMANDS } from "./vault.js";
11
+ import { CF_COMMANDS } from "./cf.js";
12
+ import { TEAM_COMMANDS } from "./team.js";
13
+ import { WORKFLOW_COMMANDS } from "./workflow.js";
14
+ import { GITFLOW_COMMANDS } from "./gitflow.js";
15
+ import { OPS_COMMANDS } from "./ops.js";
16
+ import { AUTOMATION_COMMANDS } from "./automation.js";
17
+ /** Every built-in command. Server tools are added dynamically by main() from the catalog. */
18
+ export function buildRegistry() {
19
+ const registry = new Registry();
20
+ registry.add(loginCommand, logoutCommand, whoamiCommand, helpCommand, versionCommand, completionsCommand);
21
+ registry.add(...WORKFLOW_COMMANDS);
22
+ registry.add(...CLOCK_COMMANDS);
23
+ registry.add(...TASK_COMMANDS);
24
+ registry.add(...GITFLOW_COMMANDS);
25
+ registry.add(...PROJECT_COMMANDS);
26
+ registry.add(...MEETING_COMMANDS);
27
+ registry.add(...FILE_COMMANDS);
28
+ registry.add(...VAULT_COMMANDS);
29
+ registry.add(...CF_COMMANDS);
30
+ registry.add(...TEAM_COMMANDS);
31
+ registry.add(...OPS_COMMANDS);
32
+ registry.add(...AUTOMATION_COMMANDS);
33
+ registry.add(toolsCommand, callCommand, refreshCommand);
34
+ return registry;
35
+ }