@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.
- package/GUIDE.template.md +147 -0
- package/README.md +33 -0
- package/figs.mjs +470 -0
- package/package.json +28 -0
|
@@ -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
|
+
}
|