@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 ADDED
@@ -0,0 +1,64 @@
1
+ # Fieldwork CLI (`@fieldwork-ai/cli`)
2
+
3
+ The Fieldwork CLI: drive your organization's [Fieldwork](https://app.getfieldwork.ai) assets and tools from a terminal, a script, or an agent harness like Claude Code.
4
+
5
+ ```bash
6
+ npm install -g @fieldwork-ai/cli
7
+ fieldwork login # browser sign-in; the token is pinned to the org you pick
8
+ fieldwork whoami
9
+ fieldwork tool list
10
+ ```
11
+
12
+ Inside a Fieldwork compute container the CLI is preinstalled and pre-authenticated as the conversation's user — no login step; credentials are injected per conversation.
13
+
14
+ ## Running tools
15
+
16
+ Every tool your account can use in chat (platform reads plus your org's connected integrations) is invocable by name. Arguments are one JSON object:
17
+
18
+ ```bash
19
+ fieldwork tool describe kb_search # description + input schema
20
+ fieldwork tool run kb_search --args '{"query": "replenishment thresholds"}'
21
+ echo '{"database": "Metrics DB", "sql": "select count(*) from orders"}' \
22
+ | fieldwork tool run db_query --args - # stdin for large payloads
23
+ fieldwork tool run kb_search --args '{"query": "x"}' --arg query=override
24
+ ```
25
+
26
+ `--arg key=value` overlays a string field; `--arg key:=json` parses the value as JSON. Tool-level errors (strings starting `Error:`) are data and exit 0; transport and auth failures use the exit codes below.
27
+
28
+ ## Asset families
29
+
30
+ ```bash
31
+ fieldwork agents list | get <slug>
32
+ fieldwork kb ls [prefix] | get <path> [--out <f>] | put <path> [local] | search <q> | rm <path> | mv <from> <to> | pull --dir <d> | push --dir <d>
33
+ fieldwork data list | schema <table> | query "select …"
34
+ fieldwork db list | schema <database> [table] | query <database> "select …"
35
+ fieldwork apps list | get | init | pull | publish <dir> --message "…"
36
+ fieldwork automations list | get | enable | disable | runs [--follow] | run-now [--follow] | logs <name> | delete
37
+ fieldwork connectors list | pull <key> | publish <dir> --message "…" | connect <key> --spec <file> | test <id> | secrets set | secrets pull --out <file>
38
+ ```
39
+
40
+ `kb` covers the whole Knowledge Base — markdown pages and the org's other files, one tree — dispatching on the kind the server reports rather than on the extension. `kb put <path>` writes a page from `--file`/stdin; `kb put <path> <local>` uploads a document. One group, not one per storage kind: the tree stopped being two things, and the name matches the `kb_*` tools so a person and an agent describe the same operation the same way.
41
+
42
+ Mutations prompt for confirmation on a TTY; pass `--yes` to skip (required in non-interactive use). `--json` prints stable machine-readable output on every verb. Secret values never travel via argv or stdout: `secrets set` reads from `--stdin`, `--from-env-file`, or `--from-env`; `secrets pull` writes a chmod-600 file atomically and refuses to overwrite an existing file without `--force`.
43
+
44
+ The connector development loop end to end: `connectors pull acme_erp` → edit the adapter → `connectors publish acme_erp --message "…" --base-revision N` (a new key creates the definition from the tree's `connector.json`) → `connectors connect acme_erp --spec spec.json` → `connectors secrets set <id>` → `automations run-now <sync> --follow` to watch the logs.
45
+
46
+ ## Auth and configuration
47
+
48
+ Credential resolution order: the injected container file (`~/.fieldwork/credentials`) → `FIELDWORK_TOKEN` / `FIELDWORK_API_URL` environment variables → the `fieldwork login` config (`~/.config/fieldwork/config.json`). One token binds one organization; `fieldwork orgs use` re-runs the browser flow to switch. Revoke a login anytime in Fieldwork under Settings → Connected apps.
49
+
50
+ Prefer a native tool surface in an MCP-capable harness? Fieldwork is also a remote MCP server:
51
+
52
+ ```bash
53
+ claude mcp add --transport http fieldwork https://app.getfieldwork.ai/mcp
54
+ ```
55
+
56
+ ## Exit codes
57
+
58
+ `0` success (including tool-level `Error:` strings) · `1` server/network · `2` usage or validation · `3` unauthenticated · `4` forbidden · `5` not found · `6` conflict · `7` rate-limited
59
+
60
+ ## Development and releases
61
+
62
+ Source stays in the private app repository under `cli/`. Run `pnpm install --frozen-lockfile`, `pnpm build`, and `pnpm test` here. Publish the compiled package with `npm publish --access public`; the package includes `dist/` and npm metadata, without TypeScript source or source maps. `scripts/pack-cli.sh` builds and packs this same package into the compute image without depending on an npm release.
63
+
64
+ The terminal chat client is the separate public `@fieldwork-ai/fieldwork-code` package (`fwcode`).
@@ -0,0 +1,6 @@
1
+ export type AppFiles = Record<string, string | {
2
+ encoding: "base64";
3
+ data: string;
4
+ }>;
5
+ export declare function readAppBundle(dir: string): AppFiles;
6
+ export declare function writeAppBundle(dir: string, files: AppFiles): string[];
@@ -0,0 +1,75 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ const MAX_BYTES = 10 * 1024 * 1024;
4
+ const MAX_FILES = 40;
5
+ function validPath(name) {
6
+ return name.length <= 240 && /^([a-z0-9][a-z0-9._-]*\/)*[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes("..");
7
+ }
8
+ export function readAppBundle(dir) {
9
+ const files = Object.create(null);
10
+ let total = 0;
11
+ const walk = (rel) => {
12
+ const abs = path.join(dir, rel);
13
+ const stat = lstatSync(abs);
14
+ if (stat.isSymbolicLink())
15
+ throw new Error(`Symlinks are not supported: ${rel || dir}`);
16
+ if (stat.isDirectory()) {
17
+ for (const entry of readdirSync(abs)) {
18
+ if (!entry.startsWith("."))
19
+ walk(rel ? `${rel}/${entry}` : entry);
20
+ }
21
+ return;
22
+ }
23
+ if (!stat.isFile() || !validPath(rel))
24
+ throw new Error(`Invalid app file: ${rel}`);
25
+ total += stat.size;
26
+ if (total > MAX_BYTES || Object.keys(files).length >= MAX_FILES)
27
+ throw new Error("App exceeds 10 MiB or 40 files");
28
+ const bytes = readFileSync(abs);
29
+ const text = bytes.toString("utf8");
30
+ files[rel] = !text.includes("\0") && !text.includes("\uFFFD") && Buffer.from(text).equals(bytes)
31
+ ? text : { encoding: "base64", data: bytes.toString("base64") };
32
+ };
33
+ walk("");
34
+ return files;
35
+ }
36
+ export function writeAppBundle(dir, files) {
37
+ const root = path.resolve(dir);
38
+ const safeParents = (target) => {
39
+ for (let current = target;; current = path.dirname(current)) {
40
+ try {
41
+ if (lstatSync(current).isSymbolicLink())
42
+ throw new Error(`Refusing symlink: ${current}`);
43
+ }
44
+ catch (error) {
45
+ if (error.code !== "ENOENT")
46
+ throw error;
47
+ }
48
+ if (current === root)
49
+ break;
50
+ }
51
+ };
52
+ let total = 0;
53
+ const entries = Object.entries(files);
54
+ if (entries.length > MAX_FILES)
55
+ throw new Error("App exceeds 40 files");
56
+ const prepared = entries.map(([name, file]) => {
57
+ if (!validPath(name))
58
+ throw new Error(`Invalid app path: ${name}`);
59
+ if (typeof file !== "string" && (file.encoding !== "base64" || typeof file.data !== "string"
60
+ || file.data.length > Math.ceil(MAX_BYTES / 3) * 4 || Buffer.from(file.data, "base64").toString("base64") !== file.data))
61
+ throw new Error(`Invalid binary file: ${name}`);
62
+ const bytes = typeof file === "string" ? Buffer.from(file) : Buffer.from(file.data, "base64");
63
+ total += bytes.length;
64
+ if (total > MAX_BYTES)
65
+ throw new Error("App exceeds 10 MiB");
66
+ const target = path.join(root, name);
67
+ safeParents(target);
68
+ return { name, target, bytes };
69
+ });
70
+ for (const { target, bytes } of prepared) {
71
+ mkdirSync(path.dirname(target), { recursive: true });
72
+ writeFileSync(target, bytes);
73
+ }
74
+ return prepared.map(entry => entry.name);
75
+ }
@@ -0,0 +1,5 @@
1
+ /** Read a local bundle directory into {path: content}, files only, skipping
2
+ * dotfiles — publish-side validation enforces the real limits. */
3
+ export declare function readBundle(dir: string): Record<string, string>;
4
+ /** Write a pulled {path: content} tree under dir, creating directories. */
5
+ export declare function writeBundle(dir: string, files: Record<string, string>): string[];
package/dist/bundle.js ADDED
@@ -0,0 +1,38 @@
1
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { EXIT, fail } from "./output.js";
4
+ /** Read a local bundle directory into {path: content}, files only, skipping
5
+ * dotfiles — publish-side validation enforces the real limits. */
6
+ export function readBundle(dir) {
7
+ const files = {};
8
+ const walk = (rel) => {
9
+ for (const entry of readdirSync(path.join(dir, rel))) {
10
+ if (entry.startsWith("."))
11
+ continue;
12
+ const relPath = rel ? `${rel}/${entry}` : entry;
13
+ const abs = path.join(dir, relPath);
14
+ if (statSync(abs).isDirectory())
15
+ walk(relPath);
16
+ else
17
+ files[relPath] = readFileSync(abs, "utf8");
18
+ }
19
+ };
20
+ try {
21
+ walk("");
22
+ }
23
+ catch (err) {
24
+ fail(`could not read ${dir}: ${err instanceof Error ? err.message : "unknown error"}`, EXIT.usage);
25
+ }
26
+ return files;
27
+ }
28
+ /** Write a pulled {path: content} tree under dir, creating directories. */
29
+ export function writeBundle(dir, files) {
30
+ const written = [];
31
+ for (const [file, content] of Object.entries(files)) {
32
+ const target = path.join(dir, file);
33
+ mkdirSync(path.dirname(target), { recursive: true });
34
+ writeFileSync(target, content);
35
+ written.push(file);
36
+ }
37
+ return written;
38
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAppCommands(program: Command): void;
@@ -0,0 +1,96 @@
1
+ import path from "node:path";
2
+ import { readAppBundle, writeAppBundle } from "../app-bundle.js";
3
+ import { apiJson, getContext, requireOrgId } from "../http.js";
4
+ import { EXIT, confirm, fail, note, printData } from "../output.js";
5
+ async function resolveApp(slug) {
6
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/apps?slug=${encodeURIComponent(slug)}`);
7
+ const app = body.apps?.[0];
8
+ if (!app)
9
+ fail(`no app at "${slug}"`, EXIT.notFound);
10
+ return app;
11
+ }
12
+ export function registerAppCommands(program) {
13
+ const apps = program.command("apps").description("Org apps (deployed mini-apps)");
14
+ apps
15
+ .command("list")
16
+ .description("List the apps you can access")
17
+ .action(async () => {
18
+ const { json } = getContext();
19
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/apps`);
20
+ if (json) {
21
+ printData(body, true);
22
+ return;
23
+ }
24
+ for (const app of body.apps) {
25
+ printData(`${app.slug}\tr${app.latest_revision_number}\t${app.title}`, false);
26
+ }
27
+ });
28
+ apps
29
+ .command("get <slug>")
30
+ .description("One app's metadata")
31
+ .action(async (slug) => {
32
+ printData(await resolveApp(slug), getContext().json);
33
+ });
34
+ apps
35
+ .command("init <slug>")
36
+ .description("Scaffold a new app bundle locally")
37
+ .option("--title <title>", "display title (defaults to the slug)")
38
+ .option("--out <dir>", "target directory (defaults to ./<slug>)")
39
+ .action(async (slug, opts) => {
40
+ const { json } = getContext();
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
42
+ fail(`invalid slug "${slug}" — lowercase kebab-case`, EXIT.usage);
43
+ }
44
+ const dir = opts.out ?? slug;
45
+ const title = opts.title ?? slug;
46
+ // Fetched, not vendored: the bundle's styles.css embeds the platform's
47
+ // live design tokens, which the CLI cannot compute locally (APP-89).
48
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/scaffold?title=${encodeURIComponent(title)}`);
49
+ const scaffolded = writeAppBundle(dir, body.files);
50
+ if (json) {
51
+ printData({ slug, dir, scaffolded }, true);
52
+ return;
53
+ }
54
+ note(`Scaffolded ${scaffolded.length} files in ${dir}/ — edit, then: fieldwork apps publish ${dir} --slug ${slug} --message "first deploy"`);
55
+ });
56
+ apps
57
+ .command("pull <slug>")
58
+ .description("Download an app's source tree")
59
+ .option("--out <dir>", "target directory (defaults to ./<slug>)")
60
+ .option("--revision <n>", "a historical revision instead of head")
61
+ .action(async (slug, opts) => {
62
+ const { json } = getContext();
63
+ const app = await resolveApp(slug);
64
+ const suffix = opts.revision ? `?revision=${opts.revision}` : "";
65
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/${app.app_id}/source${suffix}`);
66
+ const dir = opts.out ?? slug;
67
+ const pulled = writeAppBundle(dir, body.files);
68
+ if (json) {
69
+ printData({ slug, revision: body.revision, dir, pulled }, true);
70
+ return;
71
+ }
72
+ note(`Pulled "${slug}" revision ${body.revision} into ${dir}/ — pass --base-revision ${body.revision} when publishing.`);
73
+ });
74
+ apps
75
+ .command("publish <dir>")
76
+ .description("Deploy a bundle directory as a new revision (creates the app on first publish)")
77
+ .requiredOption("--message <message>", "what changed and why")
78
+ .option("--slug <slug>", "app slug (defaults to the directory name)")
79
+ .option("--base-revision <n>", "head revision your changes are based on (required for existing apps)")
80
+ .action(async (dir, opts) => {
81
+ const { json, yes } = getContext();
82
+ const slug = opts.slug ?? path.basename(path.resolve(dir));
83
+ const files = readAppBundle(dir);
84
+ await confirm(`Publish ${Object.keys(files).length} files to "${slug}"?`, yes);
85
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/apps/publish`, {
86
+ method: "POST",
87
+ body: JSON.stringify({
88
+ slug,
89
+ message: opts.message,
90
+ base_revision: opts.baseRevision ? Number(opts.baseRevision) : undefined,
91
+ files,
92
+ }),
93
+ });
94
+ printData(body, json);
95
+ });
96
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAssetCommands(program: Command): void;
@@ -0,0 +1,170 @@
1
+ import { apiJson, getContext, requireOrgId } from "../http.js";
2
+ import { EXIT, fail, printData, printTabular } from "../output.js";
3
+ import { applyArgEntries, readStdin } from "./tool.js";
4
+ export function registerAssetCommands(program) {
5
+ const agents = program.command("agents").description("Org agents (named personas)");
6
+ agents
7
+ .command("list")
8
+ .description("List the agents you can access")
9
+ .action(async () => {
10
+ const { json } = getContext();
11
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/agents`);
12
+ if (json) {
13
+ printData(body, true);
14
+ return;
15
+ }
16
+ for (const a of body.agents) {
17
+ printData(`${a.slug}\tr${a.latest_revision_number}\t${a.name}${a.description ? `\t${a.description}` : ""}`, false);
18
+ }
19
+ });
20
+ agents
21
+ .command("get <slug>")
22
+ .description("Read one agent in full")
23
+ .action(async (slug) => {
24
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/agents?slug=${encodeURIComponent(slug)}`);
25
+ const agent = body.agents?.[0];
26
+ if (!agent)
27
+ fail(`no agent at "${slug}"`, EXIT.notFound);
28
+ printData(agent, getContext().json);
29
+ });
30
+ const data = program.command("data").description("Curated datalake tables");
31
+ data
32
+ .command("list")
33
+ .description("List the curated tables you can read")
34
+ .action(async () => {
35
+ const { json } = getContext();
36
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables`);
37
+ if (json) {
38
+ printData(body, true);
39
+ return;
40
+ }
41
+ for (const t of body.tables) {
42
+ const rows = t.latestSnapshot ? `${t.latestSnapshot.rowCount} rows` : "no data yet";
43
+ printData(`${t.name}\t${rows}\t${t.freshness}${t.description ? `\t${t.description}` : ""}`, false);
44
+ }
45
+ });
46
+ data
47
+ .command("schema <table>")
48
+ .description("A table's columns and catalog documentation")
49
+ .action(async (table) => {
50
+ const { json } = getContext();
51
+ const list = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables`);
52
+ const found = list.tables.find((t) => t.name === table);
53
+ if (!found)
54
+ fail(`no table "${table}" — check fieldwork data list`, EXIT.notFound);
55
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/tables/${found.datalakeTableId}`);
56
+ if (json) {
57
+ printData(body, true);
58
+ return;
59
+ }
60
+ const detail = body.table;
61
+ for (const c of detail.schema?.columns ?? []) {
62
+ printData(`${c.name}\t${c.type}${c.description ? `\t${c.description}` : ""}`, false);
63
+ }
64
+ if (detail.doc)
65
+ printData(`\n${detail.doc}`, false);
66
+ });
67
+ data
68
+ .command("query [sql]")
69
+ .description("Run SQL over the curated datalake tables ('-' or omitted reads stdin)")
70
+ .option("--param <entry...>", "bind a named parameter: key=value or key:=json", [])
71
+ .option("--tables <names>", "comma-separated tables to mount (default: all readable)")
72
+ .action(async (sql, opts) => {
73
+ const { json } = getContext();
74
+ const statement = sql === undefined || sql === "-" ? await readStdin() : sql;
75
+ if (!statement?.trim())
76
+ fail("no SQL given (pass it as an argument or on stdin)", EXIT.usage);
77
+ const params = applyArgEntries({}, opts.param, "--param");
78
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/datalake/query`, {
79
+ method: "POST",
80
+ body: JSON.stringify({
81
+ sql: statement,
82
+ ...(opts.param.length > 0 ? { params } : {}),
83
+ ...(opts.tables ? { tables: opts.tables.split(",").map((t) => t.trim()) } : {}),
84
+ }),
85
+ });
86
+ printTabular(body, json);
87
+ });
88
+ const db = program.command("db").description("Connected databases (read-only)");
89
+ db.command("list")
90
+ .description("List connected databases")
91
+ .action(async () => {
92
+ const { json } = getContext();
93
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors`);
94
+ if (json) {
95
+ printData(body, true);
96
+ return;
97
+ }
98
+ for (const c of body.connectors) {
99
+ const restricted = c.restricted_tables ? "\trestricted tables" : "";
100
+ printData(`${dbAddressOf(c)}\t${c.display_name ?? c.name}\t${c.engine}\t${c.label}\tread-only ${c.read_only}${restricted}`, false);
101
+ }
102
+ });
103
+ db.command("schema <database> [table]")
104
+ .description("A database's tables, or one table's columns")
105
+ .option("--schema <schema>", "schema name (required with a table on restricted databases)")
106
+ .action(async (database, table, opts) => {
107
+ const { json } = getContext();
108
+ const row = await resolveDbConnector(database);
109
+ const query = new URLSearchParams();
110
+ if (opts.schema)
111
+ query.set("schema", opts.schema);
112
+ if (table)
113
+ query.set("table", table);
114
+ const suffix = query.size > 0 ? `?${query}` : "";
115
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors/${dbAddressOf(row)}/schema${suffix}`);
116
+ if (json) {
117
+ printData(body, true);
118
+ return;
119
+ }
120
+ if (body.columns) {
121
+ for (const c of body.columns) {
122
+ printData(`${c.name}\t${c.type}${c.nullable === false ? "\tNOT NULL" : ""}`, false);
123
+ }
124
+ }
125
+ else {
126
+ for (const t of body.tables ?? [])
127
+ printData(`${t.schema}.${t.name}`, false);
128
+ }
129
+ });
130
+ db.command("query <database> [sql]")
131
+ .description("Run a read-only SQL query ('-' or omitted reads stdin; --sql kept for compatibility)")
132
+ .option("--sql <sql>", "the SELECT to run")
133
+ .option("--param <entry...>", "bind one positional parameter (repeat in order): value or :=json", [])
134
+ .action(async (database, sql, opts) => {
135
+ const { json } = getContext();
136
+ const statement = opts.sql ?? (sql === undefined || sql === "-" ? await readStdin() : sql);
137
+ if (!statement?.trim())
138
+ fail("no SQL given (argument, --sql, or stdin)", EXIT.usage);
139
+ const row = await resolveDbConnector(database);
140
+ const values = opts.param.map((p) => p.startsWith(":=") ? JSON.parse(p.slice(2)) : p);
141
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors/${dbAddressOf(row)}/query`, {
142
+ method: "POST",
143
+ body: JSON.stringify({
144
+ sql: statement,
145
+ ...(values.length > 0 ? { params: values } : {}),
146
+ }),
147
+ });
148
+ printTabular(body, json);
149
+ });
150
+ }
151
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
152
+ /** Resolve id, name, or display name against the caller's readable list —
153
+ * the db_query resolveDatabase semantics, client-side. */
154
+ /** How a connector is addressed in a URL (ADR 20260819): the definition key for
155
+ * a global system, its id for an org-authored one. Mirrors `connectorAddress`
156
+ * in the app; the CLI is its own package and shares no code with it. */
157
+ function dbAddressOf(c) {
158
+ return c.definition_is_global ? c.definition_key : c.connector_definition_id;
159
+ }
160
+ async function resolveDbConnector(database) {
161
+ const body = await apiJson(`/api/organizations/${requireOrgId()}/db-connectors`);
162
+ const found = UUID_RE.test(database)
163
+ ? body.connectors.find((c) => c.connector_definition_id === database)
164
+ : (body.connectors.find((c) => c.definition_key === database) ??
165
+ body.connectors.find((c) => c.name === database) ??
166
+ body.connectors.find((c) => c.display_name === database));
167
+ if (!found)
168
+ fail(`no connected database "${database}" — check fieldwork db list`, EXIT.notFound);
169
+ return found;
170
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAuthCommands(program: Command): void;
@@ -0,0 +1,113 @@
1
+ import { rmSync } from "node:fs";
2
+ import { decodeToken, loginConfigPath } from "../config.js";
3
+ import { getContext } from "../http.js";
4
+ import { login } from "../oauth.js";
5
+ import { EXIT, fail, note, printData } from "../output.js";
6
+ /** Resolve the pinned org's display name with the fresh token — also the
7
+ * first live API call of the session, so login fails loudly if the token
8
+ * doesn't actually work. Falls back to the id alone. */
9
+ async function describeOrg(apiUrl, token, orgId) {
10
+ if (!orgId)
11
+ return "(unknown organization)";
12
+ try {
13
+ const res = await fetch(`${apiUrl}/api/organizations`, {
14
+ headers: { authorization: `Bearer ${token}` },
15
+ });
16
+ if (res.ok) {
17
+ const { organizations } = (await res.json());
18
+ const name = organizations.find((o) => o.organization_id === orgId)?.name;
19
+ if (name)
20
+ return `${name} (${orgId})`;
21
+ }
22
+ }
23
+ catch {
24
+ // Offline or slow API: the id alone is still correct.
25
+ }
26
+ return orgId;
27
+ }
28
+ export function registerAuthCommands(program) {
29
+ program
30
+ .command("login")
31
+ .description("Sign in via your browser (org-bound OAuth token)")
32
+ .action(async () => {
33
+ const { config, json } = getContext();
34
+ const result = await login(config.apiUrl);
35
+ const described = await describeOrg(config.apiUrl, result.access_token, result.organization_id);
36
+ note(`Signed in. Token pinned to ${described}.`);
37
+ if (json) {
38
+ printData({
39
+ user_id: decodeToken(result.access_token)?.sub ?? null,
40
+ organization_id: result.organization_id ?? null,
41
+ api_url: config.apiUrl,
42
+ }, true);
43
+ }
44
+ });
45
+ program
46
+ .command("logout")
47
+ .description("Forget the login-managed token (revoke the grant in Settings → Connected apps)")
48
+ .action(() => {
49
+ const { json } = getContext();
50
+ let removed = true;
51
+ try {
52
+ rmSync(loginConfigPath());
53
+ note("Logged out.");
54
+ }
55
+ catch {
56
+ removed = false;
57
+ note("Nothing to log out of.");
58
+ }
59
+ if (json)
60
+ printData({ logged_out: removed }, true);
61
+ });
62
+ program
63
+ .command("whoami")
64
+ .description("Who the current credential acts as (decoded locally, verified server-side)")
65
+ .action(() => {
66
+ const { config, json } = getContext();
67
+ if (!config.token) {
68
+ fail("no credentials — run `fieldwork login`", EXIT.auth);
69
+ }
70
+ const decoded = decodeToken(config.token);
71
+ if (!decoded)
72
+ fail("the current token is unreadable", EXIT.auth);
73
+ printData({
74
+ user_id: decoded.sub ?? null,
75
+ organization_id: decoded.org ?? decoded.organization_id ?? null,
76
+ conversation_id: decoded.cnv ?? null,
77
+ kind: decoded.typ === "cli" ? "container" : "oauth",
78
+ source: config.source,
79
+ expires_at: decoded.exp ? new Date(decoded.exp * 1000).toISOString() : null,
80
+ api_url: config.apiUrl,
81
+ }, json);
82
+ });
83
+ const orgs = program.command("orgs").description("Organization binding");
84
+ orgs
85
+ .command("current")
86
+ .description("The organization the current token is pinned to")
87
+ .action(() => {
88
+ const { config, json } = getContext();
89
+ const decoded = config.token ? decodeToken(config.token) : null;
90
+ const org = decoded?.org ?? decoded?.organization_id ?? null;
91
+ if (!org)
92
+ fail("no org-pinned credential — run `fieldwork login`", EXIT.auth);
93
+ printData({ organization_id: org }, json);
94
+ });
95
+ orgs
96
+ .command("use")
97
+ .description("Switch organization: re-run the browser sign-in and pick a different org")
98
+ .action(async () => {
99
+ const { config, json } = getContext();
100
+ // One token per org (the pin lives in the credential): switching is a
101
+ // fresh authorize, where the org picker/consent page names the org.
102
+ const result = await login(config.apiUrl);
103
+ const described = await describeOrg(config.apiUrl, result.access_token, result.organization_id);
104
+ note(`Now acting in ${described}.`);
105
+ if (json) {
106
+ printData({
107
+ user_id: decodeToken(result.access_token)?.sub ?? null,
108
+ organization_id: result.organization_id ?? null,
109
+ api_url: config.apiUrl,
110
+ }, true);
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAutomationCommands(program: Command): void;