@fieldwork-ai/cli 0.16.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,97 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { apiJson, requireOrgId } from "../http.js";
4
+ import { EXIT, fail } from "../output.js";
5
+ /** One page's full detail, including its serialized content. */
6
+ export async function lookupPage(kbFileId) {
7
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${kbFileId}`);
8
+ return body.page;
9
+ }
10
+ /** The server's line-based frontmatter shape (src/lib/kb/frontmatter.ts):
11
+ * `summary:` between `---` fences, body verbatim after the closing fence. */
12
+ export function parseKbContent(content) {
13
+ const lines = content.split("\n");
14
+ if (lines[0]?.trim() !== "---")
15
+ return null;
16
+ const closeIdx = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
17
+ if (closeIdx === -1)
18
+ return null;
19
+ let summary = null;
20
+ for (const line of lines.slice(1, closeIdx)) {
21
+ const match = line.match(/^summary:\s*(.*)$/);
22
+ if (match)
23
+ summary = match[1].trim();
24
+ }
25
+ if (!summary)
26
+ return null;
27
+ const body = lines
28
+ .slice(closeIdx + 1)
29
+ .join("\n")
30
+ .replace(/^\n/, "");
31
+ return { summary, body };
32
+ }
33
+ /**
34
+ * Create-or-update one page from full local content. Returns what happened.
35
+ * `existing` is the tree row when the path is already taken, resolved by the
36
+ * caller — null creates.
37
+ */
38
+ export async function putPage(pagePath, content, summaryFlag, existing) {
39
+ const parsed = parseKbContent(content);
40
+ let summary;
41
+ let body;
42
+ if (parsed) {
43
+ ({ summary, body } = parsed);
44
+ }
45
+ else if (summaryFlag) {
46
+ summary = summaryFlag;
47
+ body = content;
48
+ }
49
+ else {
50
+ fail(`${pagePath}: content must start with a frontmatter block (---\\nsummary: ...\\n---), or pass --summary to wrap it`, EXIT.usage);
51
+ }
52
+ if (existing) {
53
+ await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${existing.kb_file_id}`, {
54
+ method: "PUT",
55
+ body: JSON.stringify({
56
+ body_changed: true,
57
+ summary,
58
+ body,
59
+ base_revision_number: existing.latest_revision_number,
60
+ }),
61
+ });
62
+ return "updated";
63
+ }
64
+ await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base`, {
65
+ method: "POST",
66
+ body: JSON.stringify({ path: pagePath, summary, body }),
67
+ });
68
+ return "created";
69
+ }
70
+ export function assertSafeKbPath(pagePath) {
71
+ if (pagePath.startsWith("/") || pagePath.split("/").some((seg) => seg === "..")) {
72
+ fail(`refusing unsafe page path "${pagePath}"`, EXIT.usage);
73
+ }
74
+ }
75
+ /** All .md files under dir, keyed by their dir-relative (= KB) path. */
76
+ export function readMarkdownTree(dir) {
77
+ const files = {};
78
+ const walk = (rel) => {
79
+ for (const entry of readdirSync(path.join(dir, rel))) {
80
+ if (entry.startsWith("."))
81
+ continue;
82
+ const relPath = rel ? `${rel}/${entry}` : entry;
83
+ const abs = path.join(dir, relPath);
84
+ if (statSync(abs).isDirectory())
85
+ walk(relPath);
86
+ else if (entry.endsWith(".md"))
87
+ files[relPath] = readFileSync(abs, "utf8");
88
+ }
89
+ };
90
+ try {
91
+ walk("");
92
+ }
93
+ catch (err) {
94
+ fail(`could not read ${dir}: ${err instanceof Error ? err.message : "unknown error"}`, EXIT.usage);
95
+ }
96
+ return files;
97
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerKbCommands(program: Command): void;
@@ -0,0 +1,196 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { apiJson, getContext, requireOrgId } from "../http.js";
3
+ import { EXIT, confirm, fail, note, printData } from "../output.js";
4
+ import { writeBundle } from "../bundle.js";
5
+ import { assertSafeKbPath, lookupPage, putPage, readMarkdownTree } from "./kb-pages.js";
6
+ import { downloadFile, uploadFile } from "./kb-blobs.js";
7
+ import { isMarkdown } from "../content-kind.js";
8
+ async function listTree() {
9
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base`);
10
+ return body.files ?? [];
11
+ }
12
+ async function rowAt(kbPath) {
13
+ return (await listTree()).find((r) => r.path === kbPath) ?? null;
14
+ }
15
+ export function registerKbCommands(program) {
16
+ const kb = program
17
+ .command("kb")
18
+ .description("The Knowledge Base: markdown pages and the org's other files, in one tree");
19
+ kb
20
+ .command("ls [prefix]")
21
+ .description("List everything, optionally under a path prefix")
22
+ .action(async (prefix) => {
23
+ const { json } = getContext();
24
+ const rows = (await listTree()).filter((r) => !prefix || r.path.startsWith(prefix));
25
+ if (json) {
26
+ printData({ files: rows }, true);
27
+ return;
28
+ }
29
+ for (const r of rows) {
30
+ const page = isMarkdown(r.content_type);
31
+ const detail = page ? (r.summary ?? "") : `${r.bytes ?? 0} bytes`;
32
+ printData(`${page ? "page" : "file"}\t${r.path}\t${detail}`, false);
33
+ }
34
+ });
35
+ kb
36
+ .command("get <path>")
37
+ .description("Read a page's content, or download a file")
38
+ .option("--out <file>", 'write bytes to this file ("-" streams to stdout)')
39
+ .action(async (kbPath, opts) => {
40
+ const { json } = getContext();
41
+ const row = await rowAt(kbPath);
42
+ if (!row)
43
+ fail(`nothing at "${kbPath}"`, EXIT.notFound);
44
+ // A page prints its content; --out asks for bytes, which the download
45
+ // route serves for any row, markdown included.
46
+ if (!isMarkdown(row.content_type) || opts.out) {
47
+ await downloadFile(row, opts.out, json);
48
+ return;
49
+ }
50
+ const page = await lookupPage(row.kb_file_id);
51
+ if (json) {
52
+ printData(page, true);
53
+ return;
54
+ }
55
+ printData(page.content, false);
56
+ });
57
+ kb
58
+ .command("put <path> [localPath]")
59
+ .description("Write a page (from --file or stdin), or upload a file when localPath is given")
60
+ .option("--file <file>", "pages: read content from this file (default: stdin)")
61
+ .option("--summary <line>", "pages: wrap frontmatter-less content with this summary")
62
+ .option("--content-type <type>", "files: override the extension-derived content type")
63
+ .action(async (kbPath, localPath, opts) => {
64
+ const { json, yes } = getContext();
65
+ // A positional local path means "upload this"; everything else is a
66
+ // page write. Explicit rather than inferred from the extension, so
67
+ // uploading a .md document stays possible.
68
+ if (localPath) {
69
+ const result = await uploadFile(localPath, kbPath, opts.contentType, yes);
70
+ printData({ file_id: result.fileId, path: result.path, bytes: result.bytes }, json);
71
+ return;
72
+ }
73
+ assertSafeKbPath(kbPath);
74
+ let content;
75
+ if (opts.file) {
76
+ try {
77
+ content = readFileSync(opts.file, "utf8");
78
+ }
79
+ catch {
80
+ fail(`could not read ${opts.file}`, EXIT.usage);
81
+ }
82
+ }
83
+ else {
84
+ content = readFileSync(0, "utf8");
85
+ }
86
+ const existing = await rowAt(kbPath);
87
+ if (existing && !isMarkdown(existing.content_type)) {
88
+ fail(`"${kbPath}" holds ${existing.content_type} — replace it by uploading, not by writing a page`, EXIT.usage);
89
+ }
90
+ const outcome = await putPage(kbPath, content, opts.summary, existing);
91
+ printData({ path: kbPath, outcome }, json);
92
+ });
93
+ kb
94
+ .command("search <query>")
95
+ .description("Full-text search over pages")
96
+ .action(async (query) => {
97
+ const { json } = getContext();
98
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base?q=${encodeURIComponent(query)}`);
99
+ if (json) {
100
+ printData(body, true);
101
+ return;
102
+ }
103
+ for (const hit of body.results) {
104
+ printData(`${hit.path}: ${hit.snippet.replace(/\s+/g, " ")}`, false);
105
+ }
106
+ });
107
+ kb
108
+ .command("rm <path>")
109
+ .description("Delete a page or a file")
110
+ .action(async (kbPath) => {
111
+ const { json, yes } = getContext();
112
+ const row = await rowAt(kbPath);
113
+ if (!row)
114
+ fail(`nothing at "${kbPath}"`, EXIT.notFound);
115
+ const kind = isMarkdown(row.content_type) ? "page" : "file";
116
+ await confirm(`Delete ${kind} "${kbPath}"?`, yes);
117
+ const base = `/api/organizations/${requireOrgId()}`;
118
+ await apiJson(`${base}/knowledge-base/${row.kb_file_id}`, { method: "DELETE" });
119
+ printData({ path: kbPath, kind, deleted: true }, json);
120
+ });
121
+ kb
122
+ .command("mv <from> <to>")
123
+ .description("Move or rename a page or a file")
124
+ .action(async (from, to) => {
125
+ const { json, yes } = getContext();
126
+ const row = await rowAt(from);
127
+ if (!row)
128
+ fail(`nothing at "${from}"`, EXIT.notFound);
129
+ const kind = isMarkdown(row.content_type) ? "page" : "file";
130
+ await confirm(`Move ${kind} "${from}" to "${to}"?`, yes);
131
+ const base = `/api/organizations/${requireOrgId()}`;
132
+ await apiJson(`${base}/knowledge-base/${row.kb_file_id}`, {
133
+ method: "PATCH",
134
+ body: JSON.stringify({ path: to }),
135
+ });
136
+ printData({ from, to, kind }, json);
137
+ });
138
+ kb
139
+ .command("pull")
140
+ .description("Materialize every readable page into a directory of markdown files")
141
+ .requiredOption("--dir <dir>", "target directory")
142
+ .action(async (opts) => {
143
+ const { json } = getContext();
144
+ // Pages only: documents are bytes, and writing them into a markdown tree
145
+ // would produce files that `push` could not send back.
146
+ const rows = (await listTree()).filter((r) => isMarkdown(r.content_type));
147
+ const files = {};
148
+ for (const row of rows) {
149
+ assertSafeKbPath(row.path);
150
+ const page = await lookupPage(row.kb_file_id);
151
+ files[row.path] = page.content;
152
+ }
153
+ const pulled = writeBundle(opts.dir, files);
154
+ if (json) {
155
+ printData({ dir: opts.dir, pulled }, true);
156
+ return;
157
+ }
158
+ note(`Pulled ${pulled.length} page(s) into ${opts.dir}/`);
159
+ });
160
+ kb
161
+ .command("push")
162
+ .description("Write changed/new local markdown files back to the Knowledge Base (never prunes)")
163
+ .requiredOption("--dir <dir>", "source directory of .md files (paths mirror Knowledge Base paths)")
164
+ .option("--summary <line>", "summary for frontmatter-less new pages")
165
+ .action(async (opts) => {
166
+ const { json, yes } = getContext();
167
+ const local = readMarkdownTree(opts.dir);
168
+ const tree = new Map((await listTree()).map((r) => [r.path, r]));
169
+ const changed = [];
170
+ let unchanged = 0;
171
+ for (const [pagePath, content] of Object.entries(local)) {
172
+ assertSafeKbPath(pagePath);
173
+ const row = tree.get(pagePath);
174
+ // A path names exactly one thing: a page write must not land on bytes.
175
+ if (row && !isMarkdown(row.content_type)) {
176
+ fail(`"${pagePath}" holds ${row.content_type} — replace it by uploading, not by pushing a page`, EXIT.usage);
177
+ }
178
+ if (row && (await lookupPage(row.kb_file_id)).content === content)
179
+ unchanged += 1;
180
+ else
181
+ changed.push(pagePath);
182
+ }
183
+ if (changed.length === 0) {
184
+ if (json)
185
+ printData({ written: [], unchanged }, true);
186
+ else
187
+ note(`Nothing to push — ${unchanged} page(s) unchanged.`);
188
+ return;
189
+ }
190
+ await confirm(`Push ${changed.length} changed page(s)?\n${changed.map((p) => ` ${p}`).join("\n")}\n`, yes);
191
+ for (const pagePath of changed) {
192
+ await putPage(pagePath, local[pagePath], opts.summary, tree.get(pagePath) ?? null);
193
+ }
194
+ printData({ written: changed, unchanged }, json);
195
+ });
196
+ }
@@ -0,0 +1,28 @@
1
+ import { Command } from "commander";
2
+ export interface LabelRow {
3
+ task_label_id: string;
4
+ name: string;
5
+ color: string;
6
+ }
7
+ /**
8
+ * Add-this / drop-that arguments over the labels a task already wears.
9
+ *
10
+ * The API takes the COMPLETE set — absent leaves labels alone, an array
11
+ * replaces. People at a prompt think in deltas, though, and a `--label` that
12
+ * quietly dropped whatever else the task wore is a good way to lose a label
13
+ * nobody was looking at. So the deltas are applied over `current` here, and
14
+ * `--no-labels` is the one way to say "none".
15
+ *
16
+ * Names resolve case-insensitively, matching the server. A name matching
17
+ * nothing comes back as `missing` rather than throwing, because whether that
18
+ * is a typo or a new label is the caller's question, not this function's.
19
+ */
20
+ export declare function labelDelta(current: LabelRow[], known: LabelRow[], opts: {
21
+ add: string[];
22
+ remove: string[];
23
+ clear: boolean;
24
+ }): {
25
+ ids: string[];
26
+ missing: string[];
27
+ };
28
+ export declare function registerTaskCommands(program: Command): void;
@@ -0,0 +1,320 @@
1
+ import { apiJson, getContext, requireOrgId } from "../http.js";
2
+ import { EXIT, confirm, fail, note, printData } from "../output.js";
3
+ import { readStdin } from "./tool.js";
4
+ async function resolveTask(identifier) {
5
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks?identifier=${encodeURIComponent(identifier)}`);
6
+ const task = body.tasks?.[0];
7
+ if (!task)
8
+ fail(`No task "${identifier}"`, EXIT.notFound);
9
+ return task;
10
+ }
11
+ async function resolveProject(key) {
12
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/projects?key=${encodeURIComponent(key)}`);
13
+ const project = body.projects?.[0];
14
+ if (!project)
15
+ fail(`No project "${key}"`, EXIT.notFound);
16
+ return project;
17
+ }
18
+ /**
19
+ * Add-this / drop-that arguments over the labels a task already wears.
20
+ *
21
+ * The API takes the COMPLETE set — absent leaves labels alone, an array
22
+ * replaces. People at a prompt think in deltas, though, and a `--label` that
23
+ * quietly dropped whatever else the task wore is a good way to lose a label
24
+ * nobody was looking at. So the deltas are applied over `current` here, and
25
+ * `--no-labels` is the one way to say "none".
26
+ *
27
+ * Names resolve case-insensitively, matching the server. A name matching
28
+ * nothing comes back as `missing` rather than throwing, because whether that
29
+ * is a typo or a new label is the caller's question, not this function's.
30
+ */
31
+ export function labelDelta(current, known, opts) {
32
+ const byName = new Map(known.map((l) => [l.name.toLowerCase(), l]));
33
+ const ids = new Set(opts.clear ? [] : current.map((l) => l.task_label_id));
34
+ for (const name of opts.remove) {
35
+ const label = byName.get(name.trim().toLowerCase());
36
+ if (label)
37
+ ids.delete(label.task_label_id);
38
+ }
39
+ const missing = [];
40
+ for (const name of opts.add) {
41
+ const label = byName.get(name.trim().toLowerCase());
42
+ if (label)
43
+ ids.add(label.task_label_id);
44
+ else
45
+ missing.push(name.trim());
46
+ }
47
+ return { ids: [...ids], missing };
48
+ }
49
+ /** The same, with the project's labels read (and missing ones minted) over HTTP. */
50
+ async function resolveLabelIds(projectId, current, opts) {
51
+ if (opts.add.length === 0 && opts.remove.length === 0 && !opts.clear)
52
+ return undefined;
53
+ const route = `/api/organizations/${requireOrgId()}/projects/${projectId}/labels`;
54
+ const known = (await apiJson(route)).labels ?? [];
55
+ const { ids, missing } = labelDelta(current, known, opts);
56
+ if (missing.length > 0 && !opts.create) {
57
+ const available = known.length > 0 ? known.map((l) => l.name).join(", ") : "none yet";
58
+ fail(`No label ${missing.map((m) => `"${m}"`).join(", ")} in this project (has: ${available}). Pass --new-label to create it.`, EXIT.notFound);
59
+ }
60
+ for (const name of missing) {
61
+ const created = await apiJson(route, {
62
+ method: "POST",
63
+ body: JSON.stringify({ name }),
64
+ });
65
+ ids.push(created.label.task_label_id);
66
+ note(`Created label ${created.label.name}`);
67
+ }
68
+ return ids;
69
+ }
70
+ function formatRow(task) {
71
+ const flags = [task.status];
72
+ if (!task.is_ready && task.status !== "done" && task.status !== "canceled")
73
+ flags.push("blocked");
74
+ const assignee = task.assignee_name ?? task.assignee_email ?? "";
75
+ return `${task.identifier}\t${flags.join(",")}\t${task.title}${assignee ? `\t${assignee}` : ""}`;
76
+ }
77
+ /** `-` reads the body from stdin, matching the kb_files convention. */
78
+ async function bodyFrom(literal, flag) {
79
+ if (literal === "-") {
80
+ const piped = await readStdin();
81
+ if (piped === null)
82
+ fail(`${flag} - requires text on stdin`, EXIT.usage);
83
+ return piped;
84
+ }
85
+ if (!literal)
86
+ fail(`${flag} is required`, EXIT.usage);
87
+ return literal;
88
+ }
89
+ export function registerTaskCommands(program) {
90
+ const tasks = program.command("tasks").description("Work items (projects, statuses, blockers)");
91
+ tasks
92
+ .command("projects")
93
+ .description("List task projects")
94
+ .action(async () => {
95
+ const { json } = getContext();
96
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/projects`);
97
+ if (json) {
98
+ printData(body, true);
99
+ return;
100
+ }
101
+ for (const p of body.projects ?? []) {
102
+ printData(`${p.key}\t${p.name}\t${p.open_task_count} open, ${p.ready_task_count} ready`, false);
103
+ }
104
+ });
105
+ tasks
106
+ .command("list")
107
+ .description("List work items")
108
+ .option("--project <key>", "only this project")
109
+ .option("--status <statuses>", "comma-separated: backlog,todo,in_progress,done,canceled")
110
+ .option("--assignee <who>", '"me" or a user id')
111
+ .option("--ready", "only work that can be started right now")
112
+ .option("--parent <identifier>", "only sub-tasks of this task")
113
+ .option("--all", "include done and canceled")
114
+ .option("-q, --query <text>", "match title and description")
115
+ .action(async (opts) => {
116
+ const { json } = getContext();
117
+ const params = new URLSearchParams();
118
+ if (opts.project)
119
+ params.set("project", (await resolveProject(opts.project)).project_id);
120
+ if (opts.status)
121
+ params.set("status", opts.status);
122
+ if (opts.assignee)
123
+ params.set("assignee", opts.assignee);
124
+ if (opts.ready)
125
+ params.set("ready", "true");
126
+ if (opts.all)
127
+ params.set("include_closed", "true");
128
+ if (opts.query)
129
+ params.set("q", opts.query);
130
+ if (opts.parent)
131
+ params.set("parent", (await resolveTask(opts.parent)).task_id);
132
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks?${params.toString()}`);
133
+ if (json) {
134
+ printData(body, true);
135
+ return;
136
+ }
137
+ for (const task of body.tasks ?? [])
138
+ printData(formatRow(task), false);
139
+ });
140
+ tasks
141
+ .command("ready")
142
+ .description("Work that can be started right now (open, unblocked, no open sub-tasks)")
143
+ .option("--project <key>", "only this project")
144
+ .action(async (opts) => {
145
+ const { json } = getContext();
146
+ const params = new URLSearchParams({ ready: "true" });
147
+ if (opts.project)
148
+ params.set("project", (await resolveProject(opts.project)).project_id);
149
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks?${params.toString()}`);
150
+ if (json) {
151
+ printData(body, true);
152
+ return;
153
+ }
154
+ for (const task of body.tasks ?? [])
155
+ printData(formatRow(task), false);
156
+ });
157
+ tasks
158
+ .command("show <identifier>")
159
+ .description("One work item in full, with sub-tasks, blockers, and comments")
160
+ .action(async (identifier) => {
161
+ const task = await resolveTask(identifier);
162
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}`);
163
+ printData(body, getContext().json);
164
+ });
165
+ tasks
166
+ .command("create")
167
+ .description("Create a work item")
168
+ .requiredOption("--project <key>", "project key, e.g. OPS")
169
+ .requiredOption("--title <title>", "one-line summary")
170
+ .option("--description <text>", '"-" reads from stdin')
171
+ .option("--status <status>", "defaults to backlog")
172
+ .option("--assignee <who>", '"me" or a user id')
173
+ .option("--parent <identifier>", "make this a sub-task")
174
+ .option("--due <date>", "YYYY-MM-DD")
175
+ .option("--label <name...>", "put this label on it (repeatable)", [])
176
+ .option("--new-label", "create any --label that doesn't exist yet")
177
+ .action(async (opts) => {
178
+ const project = await resolveProject(opts.project);
179
+ const description = opts.description === undefined ? undefined : await bodyFrom(opts.description, "--description");
180
+ const labelIds = await resolveLabelIds(project.project_id, [], {
181
+ add: opts.label,
182
+ remove: [],
183
+ clear: false,
184
+ create: opts.newLabel === true,
185
+ });
186
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/projects/${project.project_id}/tasks`, {
187
+ method: "POST",
188
+ body: JSON.stringify({
189
+ title: opts.title,
190
+ description,
191
+ status: opts.status,
192
+ assigned_to_user_id: opts.assignee,
193
+ parent_task_id: opts.parent ? (await resolveTask(opts.parent)).task_id : undefined,
194
+ due_on: opts.due,
195
+ label_ids: labelIds,
196
+ }),
197
+ });
198
+ note(`Created ${body.task.identifier}`);
199
+ printData(body.task, getContext().json);
200
+ });
201
+ tasks
202
+ .command("update <identifier>")
203
+ .description("Change a work item")
204
+ .option("--title <title>")
205
+ .option("--description <text>", '"-" reads from stdin')
206
+ .option("--status <status>")
207
+ .option("--assignee <who>", '"me" or a user id')
208
+ .option("--unassign", "clear the assignee")
209
+ .option("--parent <identifier>")
210
+ .option("--no-parent", "detach from its parent")
211
+ .option("--after <identifier>", "place directly after this task")
212
+ .option("--before <identifier>", "place directly before this task")
213
+ .option("--project <key>", "move to this project (renumbers the task)")
214
+ .option("--due <date>", "YYYY-MM-DD")
215
+ .option("--no-due", "clear the due date")
216
+ .option("--label <name...>", "add this label, keeping the others (repeatable)", [])
217
+ .option("--remove-label <name...>", "take this label off (repeatable)", [])
218
+ .option("--no-labels", "take every label off")
219
+ .option("--new-label", "create any --label that doesn't exist yet")
220
+ .action(async (identifier, opts) => {
221
+ const task = await resolveTask(identifier);
222
+ const payload = {};
223
+ if (opts.title)
224
+ payload.title = opts.title;
225
+ if (opts.description !== undefined) {
226
+ payload.description = await bodyFrom(opts.description, "--description");
227
+ }
228
+ if (opts.status)
229
+ payload.status = opts.status;
230
+ if (opts.unassign)
231
+ payload.assigned_to_user_id = null;
232
+ else if (opts.assignee)
233
+ payload.assigned_to_user_id = opts.assignee;
234
+ if (opts.parent === false)
235
+ payload.parent_task_id = null;
236
+ else if (typeof opts.parent === "string") {
237
+ payload.parent_task_id = (await resolveTask(opts.parent)).task_id;
238
+ }
239
+ if (opts.after)
240
+ payload.rank_after_task_id = (await resolveTask(opts.after)).task_id;
241
+ if (opts.before)
242
+ payload.rank_before_task_id = (await resolveTask(opts.before)).task_id;
243
+ if (opts.project)
244
+ payload.project_id = (await resolveProject(opts.project)).project_id;
245
+ if (opts.due === false)
246
+ payload.due_on = null;
247
+ else if (typeof opts.due === "string")
248
+ payload.due_on = opts.due;
249
+ // Against the project the task will BE in. A move drops the old
250
+ // project's labels server-side — the destination has its own
251
+ // vocabulary — so there is nothing to carry over either.
252
+ const destination = payload.project_id ?? task.project_id;
253
+ const labelIds = await resolveLabelIds(destination, payload.project_id ? [] : task.labels, {
254
+ add: opts.label,
255
+ remove: opts.removeLabel,
256
+ clear: opts.labels === false,
257
+ create: opts.newLabel === true,
258
+ });
259
+ if (labelIds)
260
+ payload.label_ids = labelIds;
261
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}`, { method: "PATCH", body: JSON.stringify(payload) });
262
+ // A move renumbers, and a silent renumber is how people lose a task.
263
+ if (body.task.identifier !== task.identifier) {
264
+ note(`${task.identifier} → ${body.task.identifier}`);
265
+ }
266
+ printData(body.task, getContext().json);
267
+ });
268
+ tasks
269
+ .command("done <identifier>")
270
+ .description("Mark a work item done")
271
+ .action(async (identifier) => {
272
+ const task = await resolveTask(identifier);
273
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}`, { method: "PATCH", body: JSON.stringify({ status: "done" }) });
274
+ note(`${body.task.identifier} is done`);
275
+ });
276
+ tasks
277
+ .command("comment <identifier> [text]")
278
+ .description('Add a comment ("-" reads from stdin)')
279
+ .action(async (identifier, text) => {
280
+ const task = await resolveTask(identifier);
281
+ const body = await bodyFrom(text ?? "-", "comment text");
282
+ await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}/comments`, {
283
+ method: "POST",
284
+ body: JSON.stringify({ body }),
285
+ });
286
+ note(`Commented on ${task.identifier}`);
287
+ });
288
+ tasks
289
+ .command("block <identifier>")
290
+ .description("Record that this task waits on another")
291
+ .requiredOption("--by <identifier>", "the task it waits on")
292
+ .action(async (identifier, opts) => {
293
+ const [task, blocker] = [await resolveTask(identifier), await resolveTask(opts.by)];
294
+ await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}/dependencies`, {
295
+ method: "POST",
296
+ body: JSON.stringify({ blocking_task_id: blocker.task_id }),
297
+ });
298
+ note(`${task.identifier} is now blocked by ${blocker.identifier}`);
299
+ });
300
+ tasks
301
+ .command("unblock <identifier>")
302
+ .description("Clear a blocked-by relationship")
303
+ .requiredOption("--by <identifier>", "the task it no longer waits on")
304
+ .action(async (identifier, opts) => {
305
+ const [task, blocker] = [await resolveTask(identifier), await resolveTask(opts.by)];
306
+ await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}/dependencies?blocking_task_id=${blocker.task_id}`, { method: "DELETE" });
307
+ note(`${task.identifier} is no longer blocked by ${blocker.identifier}`);
308
+ });
309
+ tasks
310
+ .command("delete <identifier>")
311
+ .description("Delete a work item")
312
+ .option("--cascade", "also delete its sub-tasks")
313
+ .action(async (identifier, opts) => {
314
+ const { yes } = getContext();
315
+ const task = await resolveTask(identifier);
316
+ await confirm(`Delete ${task.identifier} "${task.title}"?`, yes);
317
+ await apiJson(`/api/organizations/${requireOrgId()}/tasks/${task.task_id}${opts.cascade ? "?cascade=true" : ""}`, { method: "DELETE" });
318
+ note(`Deleted ${task.identifier}`);
319
+ });
320
+ }
@@ -0,0 +1,12 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `fieldwork tool run <name>` argument assembly (the httpie convention):
4
+ * --args takes a JSON object ('-' reads stdin); repeated --arg entries merge
5
+ * over it — `k=v` as a string, `k:=v` JSON-parsed.
6
+ */
7
+ export declare function buildToolArgs(argsOption: string | undefined, argEntries: string[], stdin: string | null): Record<string, unknown>;
8
+ /** Merge `k=v` (string) / `k:=json` entries into base — shared by tool run's
9
+ * --arg and the query verbs' --param. */
10
+ export declare function applyArgEntries(base: Record<string, unknown>, entries: string[], flag: string): Record<string, unknown>;
11
+ export declare function readStdin(): Promise<string | null>;
12
+ export declare function registerToolCommands(program: Command): void;