@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.
- package/README.md +64 -0
- package/dist/app-bundle.d.ts +6 -0
- package/dist/app-bundle.js +75 -0
- package/dist/bundle.d.ts +5 -0
- package/dist/bundle.js +38 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +96 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +170 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +113 -0
- package/dist/commands/automations.d.ts +2 -0
- package/dist/commands/automations.js +178 -0
- package/dist/commands/connectors.d.ts +11 -0
- package/dist/commands/connectors.js +272 -0
- package/dist/commands/kb-blobs.d.ts +16 -0
- package/dist/commands/kb-blobs.js +98 -0
- package/dist/commands/kb-pages.d.ts +39 -0
- package/dist/commands/kb-pages.js +97 -0
- package/dist/commands/kb.d.ts +2 -0
- package/dist/commands/kb.js +196 -0
- package/dist/commands/tasks.d.ts +28 -0
- package/dist/commands/tasks.js +320 -0
- package/dist/commands/tool.d.ts +12 -0
- package/dist/commands/tool.js +106 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +93 -0
- package/dist/content-kind.d.ts +14 -0
- package/dist/content-kind.js +18 -0
- package/dist/http.d.ts +27 -0
- package/dist/http.js +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +46 -0
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +123 -0
- package/dist/output.d.ts +40 -0
- package/dist/output.js +90 -0
- package/package.json +39 -0
|
@@ -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,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;
|