@figs-so/cli 0.1.1

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,147 @@
1
+ <!-- This file is emitted by `figs init`. Re-run `figs init` to refresh it. -->
2
+ # Figs — how this `.figs/` folder works
3
+
4
+ You are an AI employee. **Figs** is the window your manager uses to see what you're
5
+ responsible for, what you've done, and what you need from them. You publish your state to it;
6
+ it's a **read-only mirror** — it never reaches back into your repo.
7
+
8
+ Schema details here mirror Figs' contract (the source of truth). After you edit anything, run
9
+ **`figs doctor`** — it validates `.figs/` against the live contract and tells you what's wrong.
10
+
11
+ ## The model: `.figs/` is your `dist/`
12
+
13
+ Everything you want visible goes in the `.figs/` folder, and `figs push` publishes it.
14
+ *If it's in `.figs/`, it's shared; if not, it's private.* The sync is **one-way,
15
+ append-mostly, and never deletes** on the server — the remote is the durable record, so a run
16
+ your manager signed off on doesn't vanish because you cleaned up locally.
17
+
18
+ ```
19
+ .figs/
20
+ config.json # { endpoint, workspaceId, agentId } — written by `figs init` (commit it)
21
+ agent.json # who you are: your charter/spine — you write this (commit it)
22
+ runs.jsonl # what you did, one line per run — you append (gitignored)
23
+ asks.jsonl # what you need from a human — you append (gitignored)
24
+ artifacts/ # the reports you produced — you copy in (gitignored)
25
+ GUIDE.md # this file
26
+ ```
27
+
28
+ **Commit `config.json` + `agent.json`** (your identity + charter, non-secret). The activity
29
+ files are a transient outbox — `figs init` gitignores them; the server aggregates them.
30
+
31
+ ## `agent.json` — your charter (the spine)
32
+
33
+ Write this by reading **your own repo** — your `CLAUDE.md` / `MEMORY.md` already say who you
34
+ are. Derive it, don't invent it, and keep it current as your role changes. **Do not put an
35
+ `id` here** — your identity UUID lives in `config.json` and the CLI attaches it on push.
36
+
37
+ | Field | Req | What it is |
38
+ |---|---|---|
39
+ | `name` | ✅ | Display name (e.g. "Reconciliation"). |
40
+ | `type` | | `"agent"` (default) or `"human"`. |
41
+ | `role` | | One-line title (bilingual is fine). |
42
+ | `status` | | Free text — your current state (e.g. `"in_dev"`, `"healthy"`). |
43
+ | `mandate` | | **Your 工作執掌** — one sentence: what you're accountable for. Shown loudest. |
44
+ | `avatar` | | `{ "seed": "<string>" }` — seeds your avatar. |
45
+ | `org` | | `{ "department": "...", "manager": "<member email>" }`. **`department` groups you in the org chart.** |
46
+ | `runtime` | | e.g. `"Claude Code"`. |
47
+ | `cadence` | | e.g. `"Monthly"`, `"Quarterly"`. |
48
+ | `method` | | `string[]` — **how you work**, numbered steps. The "How it works" section. |
49
+ | `properties` | | `[{ "k": "...", "v": "..." }]` — free-form facts shown on your card. |
50
+ | `units` | | `[]` — the things you're responsible for (a customer, a job). Optional; omit if none. |
51
+
52
+ **A `unit`:** `{ id, name, subtitle?, status?, period?, detail?, stats?: [{l,v}] }`. The `id`
53
+ is how your runs link to it (a run's `unit` matches a unit `id`).
54
+
55
+ ```json
56
+ {
57
+ "name": "Reconciliation",
58
+ "type": "agent",
59
+ "role": "對帳專員 · Reconciliation Officer",
60
+ "status": "in_dev",
61
+ "avatar": { "seed": "Reconciliation" },
62
+ "org": { "department": "Finance Ops · CSR", "manager": "you@company.com" },
63
+ "runtime": "Claude Code",
64
+ "cadence": "Monthly",
65
+ "mandate": "Reconciles open invoices every month — flags what doesn't match for review.",
66
+ "method": [
67
+ "Pull the WT-side open invoices and the customer-side statement for the month.",
68
+ "Match on PO / delivery-number keys within tolerance.",
69
+ "Classify every key — matched / needs-review / WT-only / cust-only — with a 'why'.",
70
+ "Surface discrepancies. Never write back to the source."
71
+ ],
72
+ "properties": [{ "k": "Department", "v": "Finance Ops · CSR" }],
73
+ "units": [
74
+ {
75
+ "id": "compal", "name": "Compal", "subtitle": "仁寶電腦",
76
+ "status": "88% matched · 31 keys flagged", "period": "2025-11",
77
+ "stats": [{ "l": "Matched", "v": "2,161 keys" }, { "l": "Needs review", "v": "31 keys" }]
78
+ }
79
+ ]
80
+ }
81
+ ```
82
+
83
+ ## `runs.jsonl` — what you did (append one line per run)
84
+
85
+ ```json
86
+ { "id": "compal-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "compal", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "artifact": "compal-2025-11.html" }
87
+ ```
88
+
89
+ - `id` ✅ and `ts` ✅ (ISO-8601 with offset) are required. `status`: `ok | warn | fail` (default `ok`).
90
+ - `unit` links to a unit `id`. `result` is the one-line outcome. `artifact` is a file in `artifacts/`.
91
+ - **Idempotent by `id`** — re-pushing the same id updates that run, never duplicates. Use a stable id.
92
+
93
+ ## `asks.jsonl` — what you need from a human (append)
94
+
95
+ Raise your hand when you're stuck. Assemble the full context so the human can act without
96
+ re-gathering anything.
97
+
98
+ ```json
99
+ {
100
+ "id": "quanta-bridge", "ts": "2026-05-28T21:05:00Z",
101
+ "type": "confirm-assumption", "status": "open", "unit": "quanta",
102
+ "title": "No bridge rule for prefixed invoice numbers",
103
+ "found": "~180 rows can't be matched safely; guessing risks false matches.",
104
+ "need": "Confirm the bridge rule for prefixed invoice numbers.",
105
+ "options": ["Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope"],
106
+ "details": [ { "l": "Amount at risk", "v": "$52.9M" } ],
107
+ "refs": [ { "label": "Quanta report", "artifact": "quanta-2025-11.html" } ]
108
+ }
109
+ ```
110
+
111
+ - Required: `id`, `type`, `title`. `type`: `blocked | needs-decision | confirm-assumption | sign-off`.
112
+ - Optional context (each renders only if present): `found`, `need`, `options[]`, `details[]`, `refs[]`.
113
+ - **You own the lifecycle.** Close an ask by appending `{ "id": "quanta-bridge", "status": "resolved" }`
114
+ on a later run. Folded by `id`. Strictly one-way — the human acts in their own workflow.
115
+
116
+ ## `artifacts/` — your reports
117
+
118
+ Drop the report a run produced here and point to it from the run's `artifact` (filename only).
119
+ Supported: **`.html` `.md` `.txt` `.json`** and images (`.png .jpg .gif .webp .svg`), **≤ 3 MB**
120
+ (compress larger images). HTML/markdown render in a sandboxed viewer; the file is shown
121
+ exactly as you produced it.
122
+
123
+ ## Lifecycle
124
+
125
+ **One-time setup** (per repo):
126
+
127
+ 1. **Log in** *(human step — a token is a secret)*: run `figs login`; your human opens the
128
+ printed link, signs in, and clicks **Approve**. (Fallback: `figs login <token>`.)
129
+ 2. **`figs init --workspace <id>`** — generates your identity UUID and writes `config.json`
130
+ + `.gitignore` + this `GUIDE.md`. Get the workspace id from `figs workspaces`.
131
+ 3. **Author `agent.json`** from your own `CLAUDE.md` / `MEMORY.md`, then **`figs doctor`**.
132
+ 4. **`figs push`** — you now appear in the org chart, self-described.
133
+
134
+ **Every run** (wire this into your run loop, not a one-off):
135
+
136
+ 1. Append a line to `runs.jsonl`; record any blockers in `asks.jsonl`.
137
+ 2. Copy the run's report into `artifacts/`.
138
+ 3. `figs push`.
139
+
140
+ ## Rules
141
+
142
+ - **One-way, never deletes.** You publish; Figs mirrors. Deleting locally doesn't delete remote.
143
+ - **You own your identity.** The UUID in `config.json` is yours — commit it so everyone running
144
+ this repo pushes to the *same* you.
145
+ - **Idempotent.** Re-running `figs push` is always safe; records fold by `id`.
146
+ - **The token is the human's job.** Never enter or generate auth tokens yourself.
147
+ - **Keep your charter honest.** Update `agent.json` when what you do changes, then `figs doctor`.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @figs-so/cli
2
+
3
+ The `figs` CLI — your AI agent publishes its state to **[Figs](https://figs.so)**, the
4
+ manager's window into the agents a company runs as back-office employees.
5
+
6
+ > Designed to be run **by the agent**: non-interactive, `--json` on read commands, errors
7
+ > that say what to do next. Zero dependencies · Node ≥ 18.
8
+
9
+ ## Use
10
+
11
+ ```bash
12
+ npx @figs-so/cli@latest login # browser approve (device flow)
13
+ npx @figs-so/cli@latest workspaces # list your workspaces
14
+ npx @figs-so/cli@latest init --workspace <id> # writes .figs/config.json + GUIDE.md
15
+ npx @figs-so/cli@latest doctor # validate .figs/ before pushing
16
+ npx @figs-so/cli@latest push # publish .figs/ to Figs
17
+ ```
18
+
19
+ After `init`, read **`.figs/GUIDE.md`** for the full agent integration guide.
20
+
21
+ ## Commands
22
+
23
+ | Command | What |
24
+ |---|---|
25
+ | `figs status [--json]` | login / workspace / agent state |
26
+ | `figs login` | device-flow browser approve (or `figs login <token>`) |
27
+ | `figs workspaces [--create <name>] [--json]` | list / create workspaces |
28
+ | `figs init --workspace <id>` | generate identity UUID + config + GUIDE.md |
29
+ | `figs doctor` | validate `.figs/` against the contract |
30
+ | `figs push` | one-way publish of `.figs/` |
31
+ | `figs version` | print version + check for updates |
32
+
33
+ Override the endpoint with `FIGS_ENDPOINT` (e.g. `http://localhost:3000` for local dev).
package/figs.mjs ADDED
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `figs` — the agent-side CLI (v1, zero-dependency).
4
+ *
5
+ * figs status show login / workspace / agent state [--json]
6
+ * figs login browser approve (device flow) — agent never sees the token
7
+ * figs login <token> fallback: save a token you pasted (~/.figs/credentials.json)
8
+ * figs workspaces [--create <name>] list (or create) the user's workspaces [--json]
9
+ * figs init --workspace <id> [--endpoint <url>]
10
+ * create .figs/config.json + GUIDE.md (generates a stable agent id)
11
+ * figs doctor validate .figs/ against the contract before pushing
12
+ * figs push one-way push the .figs/ spine to the ingest endpoint
13
+ * figs version print the CLI version (and check for updates)
14
+ *
15
+ * Designed to be driven by an agent: non-interactive, clear output, `--json`
16
+ * on read commands, and errors that say what to do next.
17
+ *
18
+ * Auth is the *user* (a token, configured once per machine). Identity is the
19
+ * *agent* — a UUID generated by `init`, stored in the committed, non-secret
20
+ * .figs/config.json ({ endpoint, workspaceId, agentId }). Run from the
21
+ * agent's repo root, where `.figs/` lives.
22
+ */
23
+
24
+ import {
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ } from "node:fs"
30
+ import { homedir } from "node:os"
31
+ import { dirname, join } from "node:path"
32
+ import { fileURLToPath } from "node:url"
33
+ import { randomUUID } from "node:crypto"
34
+
35
+ const cliDir = dirname(fileURLToPath(import.meta.url))
36
+
37
+ const VERSION = "0.1.1"
38
+ // Going-forward default; override with FIGS_ENDPOINT or .figs/config.json endpoint
39
+ // (e.g. FIGS_ENDPOINT=http://localhost:3000 for local dev).
40
+ const DEFAULT_ENDPOINT = "https://figs.so"
41
+
42
+ const repoDir = join(process.cwd(), ".figs")
43
+ const globalDir = join(homedir(), ".figs")
44
+ const globalCreds = join(globalDir, "credentials.json")
45
+ const cmd = process.argv[2] ?? "status"
46
+ const JSON_OUT = process.argv.includes("--json")
47
+
48
+ function die(msg) {
49
+ console.error(`figs: ${msg}`)
50
+ process.exit(1)
51
+ }
52
+ function readJson(path, fallback) {
53
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback
54
+ }
55
+ function flag(name) {
56
+ const i = process.argv.indexOf(name)
57
+ return i >= 0 ? process.argv[i + 1] : undefined
58
+ }
59
+ function getToken() {
60
+ return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
61
+ }
62
+ function resolveEndpoint() {
63
+ const cfg = readJson(join(repoDir, "config.json"), {})
64
+ return (process.env.FIGS_ENDPOINT || cfg.endpoint || DEFAULT_ENDPOINT).replace(
65
+ /\/+$/,
66
+ "",
67
+ )
68
+ }
69
+ /** Low-level request — returns { ok, status, data }, never throws/exits. */
70
+ async function request(method, path, body, token = getToken()) {
71
+ const base = resolveEndpoint()
72
+ let res
73
+ try {
74
+ res = await fetch(`${base}${path}`, {
75
+ method,
76
+ headers: {
77
+ "content-type": "application/json",
78
+ ...(token ? { "x-figs-token": token } : {}),
79
+ },
80
+ body: body ? JSON.stringify(body) : undefined,
81
+ })
82
+ } catch (e) {
83
+ // Network error (unreachable host, DNS, timeout) — degrade, don't crash.
84
+ return {
85
+ ok: false,
86
+ status: 0,
87
+ data: { error: `cannot reach ${base} (${e?.cause?.code || e?.code || e?.message || "network error"})` },
88
+ }
89
+ }
90
+ const text = await res.text()
91
+ let data
92
+ try {
93
+ data = text ? JSON.parse(text) : {}
94
+ } catch {
95
+ data = { raw: text }
96
+ }
97
+ return { ok: res.ok, status: res.status, data }
98
+ }
99
+ /** Authenticated request that exits with a clear message on failure. */
100
+ async function api(method, path, body) {
101
+ if (!getToken()) die("not logged in — run `figs login` (or set FIGS_TOKEN)")
102
+ const r = await request(method, path, body)
103
+ if (!r.ok) die(`${path} failed (${r.status}): ${r.data.error ?? r.data.raw ?? ""}`)
104
+ return r.data
105
+ }
106
+
107
+ function cmpSemver(a, b) {
108
+ const pa = String(a).split(".").map(Number)
109
+ const pb = String(b).split(".").map(Number)
110
+ for (let i = 0; i < 3; i++) {
111
+ const d = (pa[i] || 0) - (pb[i] || 0)
112
+ if (d) return d < 0 ? -1 : 1
113
+ }
114
+ return 0
115
+ }
116
+ /**
117
+ * Cached (daily) version check — off the hot path. Warns when behind `latest`;
118
+ * `hardFail` exits when below the compatible `min`. Network failure is ignored
119
+ * (never blocks on a transient outage).
120
+ */
121
+ async function checkVersion({ force = false, hardFail = false } = {}) {
122
+ const cachePath = join(globalDir, "version-check.json")
123
+ let info = readJson(cachePath, {})
124
+ const stale = !info.checkedAt || Date.now() - info.checkedAt > 86400000
125
+ if (force || stale) {
126
+ const r = await request("GET", "/api/version")
127
+ if (r.ok) {
128
+ info = { ...r.data, checkedAt: Date.now() }
129
+ try {
130
+ mkdirSync(globalDir, { recursive: true })
131
+ writeFileSync(cachePath, JSON.stringify(info, null, 2) + "\n")
132
+ } catch {
133
+ /* cache best-effort */
134
+ }
135
+ }
136
+ }
137
+ const min = info?.cli?.min
138
+ const latest = info?.cli?.latest
139
+ if (min && cmpSemver(VERSION, min) < 0) {
140
+ const msg = `figs CLI ${VERSION} is below the minimum ${min} — upgrade: npx @figs-so/cli@latest`
141
+ if (hardFail) die(msg)
142
+ console.warn(`figs: ! ${msg}`)
143
+ } else if (latest && cmpSemver(VERSION, latest) < 0) {
144
+ console.warn(`figs: a newer CLI is available (${latest}) — npx @figs-so/cli@latest`)
145
+ }
146
+ }
147
+
148
+ if (cmd === "login") await login(process.argv[3])
149
+ else if (cmd === "status") await status()
150
+ else if (cmd === "workspaces") await workspaces()
151
+ else if (cmd === "init") init()
152
+ else if (cmd === "doctor") await doctor()
153
+ else if (cmd === "push") await push()
154
+ else if (cmd === "version" || cmd === "--version") {
155
+ console.log(VERSION)
156
+ await checkVersion({ force: true })
157
+ } else {
158
+ die(
159
+ `unknown command "${cmd}" — try: status, login, workspaces, init, doctor, push`,
160
+ )
161
+ }
162
+
163
+ function sleep(ms) {
164
+ return new Promise((r) => setTimeout(r, ms))
165
+ }
166
+ function saveToken(token) {
167
+ mkdirSync(globalDir, { recursive: true })
168
+ writeFileSync(globalCreds, JSON.stringify({ token }, null, 2) + "\n")
169
+ }
170
+
171
+ /**
172
+ * `figs login` → device flow (the human approves in a browser; the agent never
173
+ * handles the token). `figs login <token>` → save a pasted token (fallback).
174
+ */
175
+ async function login(token) {
176
+ if (token) {
177
+ saveToken(token)
178
+ console.log("figs: ✓ token saved to ~/.figs/credentials.json")
179
+ return
180
+ }
181
+
182
+ const start = await request("POST", "/api/device/start")
183
+ if (!start.ok) die(`could not start login (${start.status})`)
184
+ const d = start.data
185
+ console.log("figs: to authorize this CLI, open the link and click Approve:")
186
+ console.log(` ${d.verification_uri_complete}`)
187
+ console.log(` (or go to ${d.verification_uri} and enter code: ${d.user_code})`)
188
+ console.log("figs: waiting for approval…")
189
+
190
+ const deadline = Date.now() + (d.expires_in ?? 600) * 1000
191
+ const wait = (d.interval ?? 5) * 1000
192
+ while (Date.now() < deadline) {
193
+ await sleep(wait)
194
+ const r = await request("POST", "/api/device/poll", { device_code: d.device_code })
195
+ const status = r.data?.status
196
+ if (status === "approved" && r.data.token) {
197
+ saveToken(r.data.token)
198
+ console.log("figs: ✓ authorized — token saved to ~/.figs/credentials.json")
199
+ return
200
+ }
201
+ if (status === "denied") die("authorization denied")
202
+ if (status === "expired") die("code expired — run `figs login` again")
203
+ // pending → keep polling
204
+ }
205
+ die("timed out waiting for approval — run `figs login` again")
206
+ }
207
+
208
+ /** Show where setup stands — login, workspace, charter. Drives the agent's next step. */
209
+ async function status() {
210
+ const token = getToken()
211
+ const cfg = readJson(join(repoDir, "config.json"), null)
212
+ const hasAgent = existsSync(join(repoDir, "agent.json"))
213
+ const endpoint = resolveEndpoint()
214
+
215
+ let loggedIn = false
216
+ let list = null
217
+ let unreachable = false
218
+ if (token) {
219
+ const r = await request("GET", "/api/workspaces", null, token)
220
+ loggedIn = r.ok
221
+ unreachable = r.status === 0
222
+ if (r.ok) list = r.data.workspaces ?? []
223
+ }
224
+
225
+ if (JSON_OUT) {
226
+ console.log(
227
+ JSON.stringify(
228
+ {
229
+ version: VERSION,
230
+ endpoint,
231
+ loggedIn,
232
+ workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
233
+ config: cfg ? { workspaceId: cfg.workspaceId, agentId: cfg.agentId } : null,
234
+ agentJson: hasAgent,
235
+ },
236
+ null,
237
+ 2,
238
+ ),
239
+ )
240
+ return
241
+ }
242
+
243
+ const row = (k, v) => console.log(` ${(k + ":").padEnd(12)} ${v}`)
244
+ console.log("figs status")
245
+ row(
246
+ "logged in",
247
+ loggedIn
248
+ ? `yes (${list.length} workspace${list.length === 1 ? "" : "s"})`
249
+ : unreachable
250
+ ? `can't reach ${endpoint}`
251
+ : token
252
+ ? "token invalid — run `figs login`"
253
+ : "no — run `figs login`",
254
+ )
255
+ row(
256
+ "workspace",
257
+ cfg?.workspaceId
258
+ ? cfg.workspaceId
259
+ : "not initialized — run `figs init --workspace <id>`",
260
+ )
261
+ row("agent.json", hasAgent ? "present" : "missing — author .figs/agent.json")
262
+ row("endpoint", endpoint)
263
+ row("cli", VERSION)
264
+ }
265
+
266
+ /** List the user's workspaces, or create one with --create "<name>". */
267
+ async function workspaces() {
268
+ const createName = flag("--create")
269
+ if (createName) {
270
+ const { workspace: ws } = await api("POST", "/api/workspaces", {
271
+ name: createName,
272
+ })
273
+ if (JSON_OUT) return void console.log(JSON.stringify(ws, null, 2))
274
+ console.log(
275
+ `figs: ✓ created "${ws.name}" (${ws.id}) — you're the owner`,
276
+ )
277
+ return
278
+ }
279
+
280
+ const { workspaces: list = [] } = await api("GET", "/api/workspaces")
281
+ if (JSON_OUT) return void console.log(JSON.stringify(list, null, 2))
282
+ if (list.length === 0) {
283
+ console.log('figs: no workspaces yet — `figs workspaces --create "<name>"`')
284
+ return
285
+ }
286
+ console.log(`figs: ${list.length} workspace${list.length === 1 ? "" : "s"}`)
287
+ for (const w of list) {
288
+ console.log(` ${String(w.role).padEnd(6)} ${w.name} ${w.id}`)
289
+ }
290
+ }
291
+
292
+ function init() {
293
+ const workspaceId = flag("--workspace")
294
+ if (!workspaceId) {
295
+ die("usage: figs init --workspace <workspaceId> [--endpoint <url>]")
296
+ }
297
+ const existing = readJson(join(repoDir, "config.json"), null)
298
+ const endpoint =
299
+ flag("--endpoint") || existing?.endpoint || "http://localhost:3000"
300
+ const agentId = existing?.agentId || randomUUID()
301
+ mkdirSync(repoDir, { recursive: true })
302
+ writeFileSync(
303
+ join(repoDir, "config.json"),
304
+ JSON.stringify({ endpoint, workspaceId, agentId }, null, 2) + "\n",
305
+ )
306
+ // Commit config.json + agent.json (identity + charter); the activity files
307
+ // are a transient outbox — emitted per run, aggregated remotely.
308
+ const giPath = join(repoDir, ".gitignore")
309
+ if (!existsSync(giPath)) {
310
+ writeFileSync(
311
+ giPath,
312
+ [
313
+ "# Figs — commit config.json + agent.json (identity + charter).",
314
+ "# Activity is a transient outbox: emitted per run, aggregated remotely.",
315
+ "runs.jsonl",
316
+ "asks.jsonl",
317
+ "artifacts/",
318
+ "credentials.json",
319
+ "",
320
+ ].join("\n"),
321
+ )
322
+ }
323
+ // Emit the agent guide (refreshed on every init).
324
+ let guide = ""
325
+ try {
326
+ writeFileSync(
327
+ join(repoDir, "GUIDE.md"),
328
+ readFileSync(join(cliDir, "GUIDE.template.md"), "utf8"),
329
+ )
330
+ guide = " + GUIDE.md"
331
+ } catch {
332
+ // template not shipped (dev) — non-fatal
333
+ }
334
+
335
+ console.log(
336
+ `figs: ✓ .figs/config.json + .gitignore${guide} written (agentId ${agentId})`,
337
+ )
338
+ console.log(" read .figs/GUIDE.md, then author .figs/agent.json and run `figs doctor`.")
339
+ }
340
+
341
+ /** Validate the local .figs/ payload against the contract — no write. */
342
+ async function doctor() {
343
+ if (!getToken()) die("not logged in — run `figs login`")
344
+ if (!existsSync(repoDir)) die("no .figs/ here — run `figs init --workspace <id>` first")
345
+ const config = readJson(join(repoDir, "config.json"), {})
346
+ if (!config.workspaceId || !config.agentId) {
347
+ die("config missing workspaceId/agentId — run `figs init --workspace <id>`")
348
+ }
349
+ const agentJson = readJson(join(repoDir, "agent.json"), null)
350
+ if (!agentJson) die("missing .figs/agent.json — author it first (see .figs/GUIDE.md)")
351
+
352
+ const r = await api("POST", "/api/validate", {
353
+ workspaceId: config.workspaceId,
354
+ agent: { ...agentJson, id: config.agentId },
355
+ runs: foldById(readJsonl("runs.jsonl")),
356
+ asks: foldById(readJsonl("asks.jsonl")),
357
+ })
358
+ if (r.ok) {
359
+ console.log("figs: ✓ .figs/ is valid — ready to push")
360
+ return
361
+ }
362
+ if (JSON_OUT) {
363
+ console.log(JSON.stringify(r.issues, null, 2))
364
+ } else {
365
+ console.log("figs: ✗ validation issues:")
366
+ for (const i of r.issues) console.log(` ${i.path || "(root)"}: ${i.message}`)
367
+ }
368
+ process.exit(1)
369
+ }
370
+
371
+ async function push() {
372
+ const token = process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
373
+ if (!token) die("not logged in — run `figs login` (or set FIGS_TOKEN)")
374
+ await checkVersion({ hardFail: true })
375
+ if (!existsSync(repoDir)) {
376
+ die("no .figs/ here — run `figs init --workspace <id>` first")
377
+ }
378
+ const config = readJson(join(repoDir, "config.json"), {})
379
+ if (!config.workspaceId || !config.agentId) {
380
+ die("config missing workspaceId/agentId — run `figs init --workspace <id>`")
381
+ }
382
+ const endpoint =
383
+ process.env.FIGS_ENDPOINT || config.endpoint || DEFAULT_ENDPOINT
384
+
385
+ const agentJson = readJson(join(repoDir, "agent.json"), null)
386
+ if (!agentJson) die("missing .figs/agent.json")
387
+ const agent = { ...agentJson, id: config.agentId }
388
+ const runs = foldById(readJsonl("runs.jsonl"))
389
+ const asks = foldById(readJsonl("asks.jsonl"))
390
+
391
+ const base = endpoint.replace(/\/+$/, "")
392
+ const res = await fetch(`${base}/api/ingest`, {
393
+ method: "POST",
394
+ headers: { "content-type": "application/json", "x-figs-token": token },
395
+ body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
396
+ })
397
+ const text = await res.text()
398
+ if (!res.ok) die(`push failed (${res.status}): ${text}`)
399
+ console.log(
400
+ `figs: ✓ pushed ${agent.name ?? agent.id} — ${runs.length} runs, ${asks.length} asks`,
401
+ )
402
+
403
+ await pushArtifacts(base, token, config, runs, asks)
404
+ }
405
+
406
+ /**
407
+ * Upload the files referenced by runs (run.artifact) and ask refs (refs[].artifact).
408
+ * The spine ingest is JSON-only; artifacts go to a separate endpoint that stores
409
+ * them content-addressed (an unchanged file is skipped server-side). Content is
410
+ * sent base64-encoded so any type — html, markdown, text, json, images — survives.
411
+ * Files larger than ~3 MB are rejected by the server; compress images if needed.
412
+ * Missing files are warned, not fatal.
413
+ */
414
+ async function pushArtifacts(base, token, config, runs, asks) {
415
+ const refNames = (asks ?? []).flatMap((a) =>
416
+ (a.refs ?? []).map((r) => r.artifact),
417
+ )
418
+ const names = [
419
+ ...new Set([...runs.map((r) => r.artifact), ...refNames].filter(Boolean)),
420
+ ]
421
+ if (names.length === 0) return
422
+
423
+ let uploaded = 0
424
+ let unchanged = 0
425
+ for (const name of names) {
426
+ const p = join(repoDir, "artifacts", name)
427
+ if (!existsSync(p)) {
428
+ console.warn(`figs: ! artifact missing, skipped: artifacts/${name}`)
429
+ continue
430
+ }
431
+ const content = readFileSync(p).toString("base64")
432
+ const res = await fetch(`${base}/api/artifacts/upload`, {
433
+ method: "POST",
434
+ headers: { "content-type": "application/json", "x-figs-token": token },
435
+ body: JSON.stringify({
436
+ workspaceId: config.workspaceId,
437
+ agentId: config.agentId,
438
+ name,
439
+ content,
440
+ }),
441
+ })
442
+ if (!res.ok) {
443
+ const t = await res.text()
444
+ console.warn(`figs: ! artifact upload failed (${res.status}) ${name}: ${t}`)
445
+ continue
446
+ }
447
+ const body = await res.json().catch(() => ({}))
448
+ if (body.unchanged) unchanged++
449
+ else uploaded++
450
+ }
451
+ console.log(
452
+ `figs: ✓ artifacts — ${uploaded} uploaded, ${unchanged} unchanged`,
453
+ )
454
+ }
455
+
456
+ function readJsonl(name) {
457
+ const p = join(repoDir, name)
458
+ if (!existsSync(p)) return []
459
+ return readFileSync(p, "utf8")
460
+ .split("\n")
461
+ .filter((l) => l.trim())
462
+ .map((l) => JSON.parse(l))
463
+ }
464
+
465
+ /** Fold append-only records by id — latest line wins. */
466
+ function foldById(rows) {
467
+ const m = new Map()
468
+ for (const r of rows) m.set(r.id, { ...(m.get(r.id) ?? {}), ...r })
469
+ return [...m.values()]
470
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@figs-so/cli",
3
+ "version": "0.1.1",
4
+ "description": "Figs CLI — publish your AI agent's state to Figs (figs.so). Run by the agent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "figs": "./figs.mjs"
8
+ },
9
+ "files": [
10
+ "figs.mjs",
11
+ "GUIDE.template.md",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "figs",
19
+ "ai-agents",
20
+ "agent",
21
+ "cli"
22
+ ],
23
+ "license": "MIT",
24
+ "homepage": "https://figs.so",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }