@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,178 @@
1
+ import { api, apiJson, getContext, requireOrgId } from "../http.js";
2
+ import { EXIT, confirm, exitCodeFor, fail, note, printData } from "../output.js";
3
+ const TERMINAL_STATUSES = new Set(["succeeded", "failed"]);
4
+ async function resolveAutomation(name) {
5
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations?name=${encodeURIComponent(name)}`);
6
+ const automation = body.automations?.[0];
7
+ if (!automation)
8
+ fail(`no automation named "${name}"`, EXIT.notFound);
9
+ return automation;
10
+ }
11
+ async function fetchRuns(automationId, query) {
12
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${automationId}/runs${query}`);
13
+ return body.runs ?? [];
14
+ }
15
+ function runStatusLine(run) {
16
+ const parts = [
17
+ `run ${run.automation_run_id}`,
18
+ run.status,
19
+ `triggered by ${run.source ?? "unknown"}`,
20
+ `created ${run.created_at}`,
21
+ ];
22
+ if (run.finished_at)
23
+ parts.push(`finished ${run.finished_at}`);
24
+ return parts.join(" · ");
25
+ }
26
+ /** Print one finished (or latest) run: status line to stderr, log tail to
27
+ * stdout. Exit code reflects the outcome; an in-flight run exits 0. */
28
+ function printRun(run, json) {
29
+ if (json) {
30
+ printData(run, true);
31
+ }
32
+ else {
33
+ note(runStatusLine(run));
34
+ if (run.error)
35
+ note(`error: ${run.error}`);
36
+ if (run.log_tail)
37
+ printData(run.log_tail, false);
38
+ }
39
+ if (run.status === "failed")
40
+ process.exit(EXIT.server);
41
+ if (!TERMINAL_STATUSES.has(run.status)) {
42
+ note("still running — follow it with: fieldwork automations runs <name> --follow");
43
+ }
44
+ }
45
+ /** Poll one run to a terminal status, narrating transitions on stderr. */
46
+ async function followRun(automationId, runId, intervalSeconds, json) {
47
+ let lastStatus = null;
48
+ for (;;) {
49
+ const run = (await fetchRuns(automationId, `?run=${runId}`))[0];
50
+ if (!run)
51
+ fail(`run ${runId} disappeared`, EXIT.notFound);
52
+ if (run.status !== lastStatus) {
53
+ note(runStatusLine(run));
54
+ lastStatus = run.status;
55
+ }
56
+ if (TERMINAL_STATUSES.has(run.status)) {
57
+ if (json)
58
+ printData(run, true);
59
+ else {
60
+ if (run.error)
61
+ note(`error: ${run.error}`);
62
+ if (run.log_tail)
63
+ printData(run.log_tail, false);
64
+ }
65
+ if (run.status === "failed")
66
+ process.exit(EXIT.server);
67
+ return;
68
+ }
69
+ await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
70
+ }
71
+ }
72
+ export function registerAutomationCommands(program) {
73
+ const automations = program
74
+ .command("automations")
75
+ .description("Standing instructions that run unattended (schedules, webhooks)");
76
+ automations
77
+ .command("list")
78
+ .description("List the automations you can see")
79
+ .option("--kind <kind>", "filter by kind: action | agent")
80
+ .action(async (opts) => {
81
+ const { json } = getContext();
82
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations`);
83
+ const rows = opts.kind
84
+ ? body.automations.filter((a) => a.kind === opts.kind)
85
+ : body.automations;
86
+ if (json) {
87
+ printData({ automations: rows }, true);
88
+ return;
89
+ }
90
+ for (const a of rows) {
91
+ printData(`${a.name}\t${a.kind}\t${a.enabled ? "enabled" : "DISABLED"}`, false);
92
+ }
93
+ });
94
+ automations
95
+ .command("get <name>")
96
+ .description("One automation in full (includes the webhook URL — treat as a credential)")
97
+ .action(async (name) => {
98
+ const found = await resolveAutomation(name);
99
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`);
100
+ printData(body, getContext().json);
101
+ });
102
+ const setEnabled = (enabled) => async (name) => {
103
+ const { json, yes } = getContext();
104
+ const found = await resolveAutomation(name);
105
+ await confirm(`${enabled ? "Enable" : "Disable"} "${name}"?`, yes);
106
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
107
+ printData(body, json);
108
+ };
109
+ automations.command("enable <name>").description("Enable an automation").action(setEnabled(true));
110
+ automations
111
+ .command("disable <name>")
112
+ .description("Disable an automation")
113
+ .action(setEnabled(false));
114
+ automations
115
+ .command("delete <name>")
116
+ .description("Delete an automation")
117
+ .action(async (name) => {
118
+ const { json, yes } = getContext();
119
+ const found = await resolveAutomation(name);
120
+ await confirm(`Delete "${name}"? Its run history is retained.`, yes);
121
+ const res = await api(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}`, { method: "DELETE" });
122
+ if (res.status !== 204) {
123
+ const body = (await res.json().catch(() => null));
124
+ fail(body?.error ?? `delete failed (${res.status})`, exitCodeFor(res.status));
125
+ }
126
+ printData({ deleted: true, automation_id: found.automation_id, name }, json);
127
+ });
128
+ automations
129
+ .command("runs <name>")
130
+ .description("Recent runs; --follow polls the latest run to completion")
131
+ .option("--limit <n>", "how many (default 20, max 100)")
132
+ .option("--follow", "poll the latest run until it succeeds or fails")
133
+ .option("--interval <s>", "poll interval in seconds (default 3)", "3")
134
+ .action(async (name, opts) => {
135
+ const { json } = getContext();
136
+ const found = await resolveAutomation(name);
137
+ if (opts.follow) {
138
+ const latest = (await fetchRuns(found.automation_id, "?limit=1"))[0];
139
+ if (!latest)
140
+ fail(`"${name}" has no runs yet`, EXIT.notFound);
141
+ await followRun(found.automation_id, latest.automation_run_id, Number(opts.interval) || 3, json);
142
+ return;
143
+ }
144
+ const suffix = opts.limit ? `?limit=${opts.limit}` : "";
145
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}/runs${suffix}`);
146
+ printData(body, json);
147
+ });
148
+ automations
149
+ .command("logs <name>")
150
+ .description("A run's captured log tail (default: the latest run)")
151
+ .option("--run <id>", "a specific run id (from automations runs)")
152
+ .action(async (name, opts) => {
153
+ const { json } = getContext();
154
+ const found = await resolveAutomation(name);
155
+ const query = opts.run ? `?run=${encodeURIComponent(opts.run)}` : "?limit=1";
156
+ const run = (await fetchRuns(found.automation_id, query))[0];
157
+ if (!run) {
158
+ fail(opts.run ? `no run ${opts.run} for "${name}"` : `"${name}" has no runs yet`, EXIT.notFound);
159
+ }
160
+ printRun(run, json);
161
+ });
162
+ automations
163
+ .command("run-now <name>")
164
+ .description("Create a manual run")
165
+ .option("--follow", "poll the run until it succeeds or fails")
166
+ .option("--interval <s>", "poll interval in seconds (default 3)", "3")
167
+ .action(async (name, opts) => {
168
+ const { json, yes } = getContext();
169
+ const found = await resolveAutomation(name);
170
+ await confirm(`Run "${name}" now?`, yes);
171
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/automations/${found.automation_id}/runs`, { method: "POST" });
172
+ if (opts.follow) {
173
+ await followRun(found.automation_id, body.run.automation_run_id, Number(opts.interval) || 3, json);
174
+ return;
175
+ }
176
+ printData(body, json);
177
+ });
178
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Secret files are written via a temp file in the target directory: openSync's
4
+ * mode only applies on create, so writing straight over an existing
5
+ * looser-mode file would silently keep that mode on the credentials; the
6
+ * rename also means no reader ever observes a truncated or partial file.
7
+ */
8
+ export declare function writeSecretsFile(out: string, secrets: Record<string, string>, opts: {
9
+ force: boolean;
10
+ }): void;
11
+ export declare function registerConnectorCommands(program: Command): void;
@@ -0,0 +1,272 @@
1
+ import { closeSync, existsSync, fchmodSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { readBundle, writeBundle } from "../bundle.js";
4
+ import { apiJson, getContext, requireOrgId } from "../http.js";
5
+ import { EXIT, confirm, fail, note, printData } from "../output.js";
6
+ async function resolveDefinition(key) {
7
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions?include_drafts=true`);
8
+ const matches = (body.definitions ?? []).filter((d) => d.key === key);
9
+ const definition = matches.find((d) => d.org_private) ?? matches[0];
10
+ if (!definition) {
11
+ fail(`no connector definition with key "${key}"`, EXIT.notFound);
12
+ }
13
+ return definition;
14
+ }
15
+ function parseDotenv(content) {
16
+ const values = {};
17
+ for (const line of content.split("\n")) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith("#"))
20
+ continue;
21
+ const idx = trimmed.indexOf("=");
22
+ if (idx <= 0)
23
+ continue;
24
+ values[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
25
+ }
26
+ return values;
27
+ }
28
+ async function readSecretInput(opts) {
29
+ if (opts.stdin) {
30
+ if (process.stdin.isTTY)
31
+ fail("--stdin expects a JSON object piped in", EXIT.usage);
32
+ const chunks = [];
33
+ for await (const chunk of process.stdin)
34
+ chunks.push(chunk);
35
+ try {
36
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
37
+ if (typeof parsed !== "object" || parsed === null)
38
+ throw new Error("not an object");
39
+ return parsed;
40
+ }
41
+ catch {
42
+ fail("stdin must carry a JSON object of {name: value}", EXIT.usage);
43
+ }
44
+ }
45
+ if (opts.fromEnvFile) {
46
+ try {
47
+ return parseDotenv(readFileSync(opts.fromEnvFile, "utf8"));
48
+ }
49
+ catch {
50
+ fail(`could not read ${opts.fromEnvFile}`, EXIT.usage);
51
+ }
52
+ }
53
+ if (opts.fromEnv) {
54
+ const values = {};
55
+ for (const name of opts.fromEnv.split(",").map((n) => n.trim())) {
56
+ const value = process.env[name];
57
+ if (value === undefined)
58
+ fail(`environment variable ${name} is not set`, EXIT.usage);
59
+ values[name] = value;
60
+ }
61
+ return values;
62
+ }
63
+ fail("provide secrets via --stdin, --from-env-file <file>, or --from-env NAME,...", EXIT.usage);
64
+ }
65
+ /**
66
+ * Secret files are written via a temp file in the target directory: openSync's
67
+ * mode only applies on create, so writing straight over an existing
68
+ * looser-mode file would silently keep that mode on the credentials; the
69
+ * rename also means no reader ever observes a truncated or partial file.
70
+ */
71
+ export function writeSecretsFile(out, secrets, opts) {
72
+ if (!opts.force && existsSync(out)) {
73
+ fail(`${out} already exists — pass --force to overwrite`, EXIT.usage);
74
+ }
75
+ const tmp = join(dirname(out), `.${basename(out)}.tmp-${process.pid}`);
76
+ rmSync(tmp, { force: true });
77
+ const fd = openSync(tmp, "wx", 0o600);
78
+ try {
79
+ fchmodSync(fd, 0o600); // openSync's mode is masked by umask; this is not
80
+ const lines = Object.entries(secrets).map(([name, value]) => `${name}=${value}`);
81
+ writeFileSync(fd, `${lines.join("\n")}\n`);
82
+ }
83
+ finally {
84
+ closeSync(fd);
85
+ }
86
+ try {
87
+ renameSync(tmp, out);
88
+ }
89
+ catch (err) {
90
+ rmSync(tmp, { force: true });
91
+ throw err;
92
+ }
93
+ }
94
+ /** How a connector is addressed in a URL (ADR 20260819): the definition key for
95
+ * a global system, its id for an org-authored one whose key is only unique
96
+ * within the org. Mirrors `connectorAddress` in the app. */
97
+ function addressOf(c) {
98
+ return c.definition_is_global ? c.definition_key : c.connector_definition_id;
99
+ }
100
+ async function listConnectors() {
101
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors`);
102
+ return body.connectors;
103
+ }
104
+ export function registerConnectorCommands(program) {
105
+ const connectors = program.command("connectors").description("Org connectors");
106
+ connectors
107
+ .command("list")
108
+ .description("List the org's connectors")
109
+ .action(async () => {
110
+ const { json } = getContext();
111
+ const connectors = await listConnectors();
112
+ if (json) {
113
+ printData({ connectors }, true);
114
+ return;
115
+ }
116
+ for (const c of connectors) {
117
+ printData(`${addressOf(c)}\t${c.name}${c.connected ? "\tconnected" : ""}`, false);
118
+ }
119
+ });
120
+ connectors
121
+ .command("connect <key>")
122
+ .description("Provision a data source: connector + tables + schedule against an existing definition")
123
+ .requiredOption("--spec <file>", 'JSON spec {connector: {name, config}, tables: [...], schedule: {...}} ("-" reads stdin)')
124
+ .action(async (key, opts) => {
125
+ const { json, yes } = getContext();
126
+ let raw;
127
+ try {
128
+ raw = opts.spec === "-" ? readFileSync(0, "utf8") : readFileSync(opts.spec, "utf8");
129
+ }
130
+ catch {
131
+ fail(`could not read ${opts.spec}`, EXIT.usage);
132
+ }
133
+ let spec;
134
+ try {
135
+ spec = JSON.parse(raw);
136
+ }
137
+ catch {
138
+ fail(`${opts.spec} is not valid JSON`, EXIT.usage);
139
+ }
140
+ await confirm(`Connect "${key}" as "${spec.connector?.name ?? "?"}" — ${spec.tables?.length ?? 0} table(s), every ${spec.schedule?.interval ?? "?"}?`, yes);
141
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/connectors/sync`, {
142
+ method: "POST",
143
+ body: JSON.stringify({ definition_key: key, ...spec }),
144
+ });
145
+ if (body.connector.awaiting_credentials) {
146
+ note(`Awaiting credentials — set them with: fieldwork connectors secrets set ${key}`);
147
+ }
148
+ printData(body, json);
149
+ });
150
+ connectors
151
+ .command("test <keyOrName>")
152
+ .description("Test a saved database connector's stored credentials")
153
+ .action(async (keyOrName) => {
154
+ const { json } = getContext();
155
+ const connectors = await listConnectors();
156
+ const match = connectors.find((c) => c.definition_key === keyOrName) ??
157
+ connectors.find((c) => c.name === keyOrName);
158
+ if (!match)
159
+ fail(`no connector "${keyOrName}"`, EXIT.notFound);
160
+ // No body on purpose: a body is treated as an unsaved-override test and
161
+ // skips the persisted read-only re-validation.
162
+ const body = await apiJson(`/api/connectors/${addressOf(match)}/test`, { method: "POST" });
163
+ printData(body, json);
164
+ if (!body.ok)
165
+ process.exit(EXIT.server);
166
+ });
167
+ connectors
168
+ .command("pull <key>")
169
+ .description("Download an org-authored connector definition's adapter source")
170
+ .option("--out <dir>", "target directory (defaults to ./<key>)")
171
+ .option("--revision <n>", "a historical revision instead of head")
172
+ .option("--action-name <name>", "the named action under this connector")
173
+ .action(async (key, opts) => {
174
+ const { json } = getContext();
175
+ const definition = await resolveDefinition(key);
176
+ const query = new URLSearchParams();
177
+ if (opts.revision)
178
+ query.set("revision", opts.revision);
179
+ if (opts.actionName)
180
+ query.set("action_name", opts.actionName);
181
+ const suffix = query.size ? `?${query}` : "";
182
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions/${definition.connector_definition_id}/source${suffix}`);
183
+ const dir = opts.out ?? key;
184
+ const pulled = writeBundle(dir, body.files);
185
+ if (json) {
186
+ printData({ key, revision: body.revision, dir, pulled }, true);
187
+ return;
188
+ }
189
+ note(`Pulled "${key}" revision ${body.revision} into ${dir}/ — pass --base-revision ${body.revision} when publishing.`);
190
+ });
191
+ connectors
192
+ .command("publish <dir>")
193
+ .description("Publish an adapter directory as a new revision (creates the definition on first publish)")
194
+ .requiredOption("--message <message>", "what changed and why")
195
+ .option("--base-revision <n>", "head revision your changes are based on (required once code is published)")
196
+ .option("--action-name <name>", "create or update this named action under the connector")
197
+ .action(async (dir, opts) => {
198
+ const { json, yes } = getContext();
199
+ const files = readBundle(dir);
200
+ // Local parse is only for the confirm prompt — the server re-parses
201
+ // the manifest authoritatively.
202
+ let key = "?";
203
+ try {
204
+ const manifest = JSON.parse(files["connector.json"] ?? "");
205
+ if (typeof manifest?.key === "string")
206
+ key = manifest.key;
207
+ }
208
+ catch {
209
+ // fall through: the server reports the real validation error
210
+ }
211
+ if (key === "?") {
212
+ fail('the tree\'s connector.json must carry a "key" field', EXIT.usage);
213
+ }
214
+ await confirm(`Publish ${Object.keys(files).length} files to connector "${key}"${opts.actionName ? `, action "${opts.actionName}"` : ""}?`, yes);
215
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/connector-definitions/publish`, {
216
+ method: "POST",
217
+ body: JSON.stringify({
218
+ message: opts.message,
219
+ base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
220
+ action_name: opts.actionName,
221
+ files,
222
+ }),
223
+ });
224
+ printData(body, json);
225
+ });
226
+ const secrets = connectors
227
+ .command("secrets")
228
+ .description("Connector credentials (values never transit argv or stdout)");
229
+ secrets
230
+ .command("names <connector>")
231
+ .description("The secret names currently set, plus the definition's hints")
232
+ .action(async (connector) => {
233
+ printData(await apiJson(`/api/connectors/${connector}/secrets`), getContext().json);
234
+ });
235
+ secrets
236
+ .command("set <connector>")
237
+ .description("Replace the connector's secrets envelope (write-only)")
238
+ .option("--stdin", "read a JSON object of {name: value} from stdin")
239
+ .option("--from-env-file <file>", "read NAME=value lines from a dotenv file")
240
+ .option("--from-env <names>", "comma-separated env var names to copy")
241
+ .action(async (connector, opts) => {
242
+ const { json, yes } = getContext();
243
+ const values = await readSecretInput(opts);
244
+ const names = Object.keys(values);
245
+ if (names.length === 0)
246
+ fail("no secrets to set", EXIT.usage);
247
+ await confirm(`Replace the secrets envelope with ${names.length} value(s)?`, yes);
248
+ const body = await apiJson(`/api/connectors/${connector}/secrets`, {
249
+ method: "PUT",
250
+ body: JSON.stringify({ secrets: values }),
251
+ });
252
+ printData(body, json);
253
+ });
254
+ secrets
255
+ .command("pull <connector>")
256
+ .description("Materialize an org-authored connector's secret values into a file")
257
+ .requiredOption("--out <file>", "target dotenv file (written chmod 600)")
258
+ .option("--force", "overwrite an existing file")
259
+ .action(async (connector, opts) => {
260
+ const { json } = getContext();
261
+ const body = await apiJson(`/api/connectors/${connector}/secrets/values`);
262
+ writeSecretsFile(opts.out, body.secrets, { force: Boolean(opts.force) });
263
+ const names = Object.keys(body.secrets);
264
+ // Names only — the values are in the file, never on a stream.
265
+ if (json) {
266
+ printData({ out: opts.out, names }, true);
267
+ return;
268
+ }
269
+ note(`Wrote ${names.length} secret(s) to ${opts.out}: ${names.join(", ")}`);
270
+ note(`Use them without echoing: set -a && . ${opts.out} && set +a`);
271
+ });
272
+ }
@@ -0,0 +1,16 @@
1
+ export interface FileRow {
2
+ kb_file_id: string;
3
+ path: string;
4
+ }
5
+ /**
6
+ * The three-step upload: POST the row, PUT to the presigned URL, confirm.
7
+ * Extracted so `kb put` can reach it — the unified command dispatches on
8
+ * kind, and a document takes exactly this path.
9
+ */
10
+ export declare function uploadFile(localPath: string, remotePath: string | undefined, contentTypeOverride: string | undefined, yes: boolean): Promise<{
11
+ fileId: string;
12
+ path: string;
13
+ bytes: number;
14
+ }>;
15
+ /** Write one row's bytes to `out` ("-" streams to stdout). */
16
+ export declare function downloadFile(file: FileRow, out: string | undefined, json: boolean): Promise<void>;
@@ -0,0 +1,98 @@
1
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { apiJson, requireOrgId } from "../http.js";
4
+ import { EXIT, confirm, fail, note, printData } from "../output.js";
5
+ /**
6
+ * Document primitives for the `kb_files` group: the three-step upload flow
7
+ * (POST row → PUT to the presigned URL → confirm) and the presigned download.
8
+ * The presigned URLs are absolute and carry no bearer — raw fetch, never api().
9
+ *
10
+ * No command group of its own: `files` split the tree by storage kind, which
11
+ * is what the Knowledge Base merge removed.
12
+ */
13
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
14
+ const CONTENT_TYPES = {
15
+ ".csv": "text/csv",
16
+ ".json": "application/json",
17
+ ".jsonl": "application/jsonl",
18
+ ".md": "text/markdown",
19
+ ".txt": "text/plain",
20
+ ".pdf": "application/pdf",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".webp": "image/webp",
25
+ ".svg": "image/svg+xml",
26
+ ".parquet": "application/vnd.apache.parquet",
27
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
28
+ ".zip": "application/zip",
29
+ };
30
+ function guessContentType(filename) {
31
+ return CONTENT_TYPES[path.extname(filename).toLowerCase()] ?? "application/octet-stream";
32
+ }
33
+ /**
34
+ * The three-step upload: POST the row, PUT to the presigned URL, confirm.
35
+ * Extracted so `kb put` can reach it — the unified command dispatches on
36
+ * kind, and a document takes exactly this path.
37
+ */
38
+ export async function uploadFile(localPath, remotePath, contentTypeOverride, yes) {
39
+ let size;
40
+ try {
41
+ size = statSync(localPath).size;
42
+ }
43
+ catch {
44
+ fail(`could not read ${localPath}`, EXIT.usage);
45
+ }
46
+ if (size > MAX_FILE_BYTES) {
47
+ fail(`${localPath} is ${size} bytes — the server caps uploads at 100MB`, EXIT.usage);
48
+ }
49
+ const remote = remotePath ?? path.basename(localPath);
50
+ const contentType = contentTypeOverride ?? guessContentType(remote);
51
+ await confirm(`Upload ${localPath} (${size} bytes) to "${remote}"?`, yes);
52
+ const created = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base`, {
53
+ method: "POST",
54
+ body: JSON.stringify({ filename: remote, contentType, sizeBytes: size }),
55
+ });
56
+ // Content-Type must match the presign's pinned value exactly. A failed
57
+ // PUT leaves only an invisible pending row — nothing to clean up.
58
+ const put = await fetch(created.uploadUrl, {
59
+ method: "PUT",
60
+ headers: { "content-type": contentType },
61
+ body: readFileSync(localPath),
62
+ }).catch((err) => {
63
+ fail(`upload failed: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
64
+ });
65
+ if (!put.ok) {
66
+ fail(`upload failed: storage returned ${put.status}`, EXIT.server);
67
+ }
68
+ // Confirm names the revision the bytes landed on: nothing is visible at the
69
+ // path until it does, so an abandoned upload cannot hold a path.
70
+ await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${created.fileId}/confirm`, {
71
+ method: "POST",
72
+ body: JSON.stringify({ path: remote, revisionId: created.revisionId, contentType }),
73
+ });
74
+ return { fileId: created.fileId, path: remote, bytes: size };
75
+ }
76
+ /** Write one row's bytes to `out` ("-" streams to stdout). */
77
+ export async function downloadFile(file, out, json) {
78
+ const remotePath = file.path;
79
+ const { url } = await apiJson(`/api/organizations/${requireOrgId()}/knowledge-base/${file.kb_file_id}/download`);
80
+ const res = await fetch(url).catch((err) => {
81
+ fail(`download failed: ${err instanceof Error ? err.message : "network error"}`, EXIT.server);
82
+ });
83
+ if (!res.ok)
84
+ fail(`download failed: storage returned ${res.status}`, EXIT.server);
85
+ const bytes = Buffer.from(await res.arrayBuffer());
86
+ if (out === "-") {
87
+ // Binary-safe: raw bytes to stdout, nothing else on the stream.
88
+ process.stdout.write(bytes);
89
+ return;
90
+ }
91
+ const target = out ?? path.basename(remotePath);
92
+ writeFileSync(target, bytes);
93
+ if (json) {
94
+ printData({ file_id: file.kb_file_id, path: remotePath, bytes: bytes.length, out: target }, true);
95
+ return;
96
+ }
97
+ note(`Wrote ${bytes.length} bytes to ${target}`);
98
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Knowledge base family (ADR 20260804 revision: purpose verbs are
3
+ * REST-backed). Mutations ride the same UI routes the in-app editor uses —
4
+ * writeKbPage stays the single server-side write path. Local files carry the
5
+ * page's full content including its frontmatter block, so pull/push
6
+ * round-trips byte-for-byte.
7
+ *
8
+ * Callers resolve a path to a row against the tree listing and pass the id in.
9
+ * There is deliberately no path lookup here: the merged API addresses a file
10
+ * by id, and a second path→row mechanism is what left this module pointed at
11
+ * routes that no longer existed.
12
+ */
13
+ export interface KbPageDetail {
14
+ kb_file_id: string;
15
+ path: string;
16
+ summary: string | null;
17
+ latest_revision_number: number;
18
+ content: string;
19
+ }
20
+ /** One page's full detail, including its serialized content. */
21
+ export declare function lookupPage(kbFileId: string): Promise<KbPageDetail>;
22
+ /** The server's line-based frontmatter shape (src/lib/kb/frontmatter.ts):
23
+ * `summary:` between `---` fences, body verbatim after the closing fence. */
24
+ export declare function parseKbContent(content: string): {
25
+ summary: string;
26
+ body: string;
27
+ } | null;
28
+ /**
29
+ * Create-or-update one page from full local content. Returns what happened.
30
+ * `existing` is the tree row when the path is already taken, resolved by the
31
+ * caller — null creates.
32
+ */
33
+ export declare function putPage(pagePath: string, content: string, summaryFlag: string | undefined, existing: {
34
+ kb_file_id: string;
35
+ latest_revision_number: number;
36
+ } | null): Promise<"created" | "updated">;
37
+ export declare function assertSafeKbPath(pagePath: string): void;
38
+ /** All .md files under dir, keyed by their dir-relative (= KB) path. */
39
+ export declare function readMarkdownTree(dir: string): Record<string, string>;