@figs-so/cli 0.1.3 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/figs.mjs +86 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,6 +25,7 @@ The full, always-current guide + `agent.json` schema is served at **`<endpoint>/
25
25
  |---|---|
26
26
  | `figs status [--json]` | login / workspace / agent state |
27
27
  | `figs login` | device-flow browser approve (or `figs login <token>`) |
28
+ | `figs logout` | remove the locally-saved token (`~/.figs/credentials.json`) |
28
29
  | `figs workspaces [--json]` | list your workspaces (read-only; create one in the web app) |
29
30
  | `figs init --workspace <slug-or-id>` | resolve the workspace, generate identity UUID + config + pointer GUIDE.md |
30
31
  | `figs doctor` | validate `.figs/` against the contract |
package/figs.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * figs status show login / workspace / agent state [--json]
6
6
  * figs login browser approve (device flow) — agent never sees the token
7
7
  * figs login <token> fallback: save a token you pasted (~/.figs/credentials.json)
8
+ * figs logout remove the locally-saved token (~/.figs/credentials.json)
8
9
  * figs workspaces list the user's workspaces [--json]
9
10
  * figs init --workspace <slug-or-id> [--endpoint <url>]
10
11
  * create .figs/config.json + GUIDE.md (generates a stable agent id)
@@ -25,13 +26,14 @@ import {
25
26
  existsSync,
26
27
  mkdirSync,
27
28
  readFileSync,
29
+ rmSync,
28
30
  writeFileSync,
29
31
  } from "node:fs"
30
32
  import { homedir } from "node:os"
31
33
  import { join } from "node:path"
32
34
  import { randomUUID } from "node:crypto"
33
35
 
34
- const VERSION = "0.1.3"
36
+ const VERSION = "0.1.6"
35
37
  // Going-forward default; override with FIGS_ENDPOINT or .figs/config.json endpoint
36
38
  // (e.g. FIGS_ENDPOINT=http://localhost:3000 for local dev).
37
39
  const DEFAULT_ENDPOINT = "https://app.figs.so"
@@ -143,6 +145,7 @@ async function checkVersion({ force = false, hardFail = false } = {}) {
143
145
  }
144
146
 
145
147
  if (cmd === "login") await login(process.argv[3])
148
+ else if (cmd === "logout") logout()
146
149
  else if (cmd === "status") await status()
147
150
  else if (cmd === "workspaces") await workspaces()
148
151
  else if (cmd === "init") await init()
@@ -153,7 +156,7 @@ else if (cmd === "version" || cmd === "--version") {
153
156
  await checkVersion({ force: true })
154
157
  } else {
155
158
  die(
156
- `unknown command "${cmd}" — try: status, login, workspaces, init, doctor, push`,
159
+ `unknown command "${cmd}" — try: status, login, logout, workspaces, init, doctor, push`,
157
160
  )
158
161
  }
159
162
 
@@ -197,16 +200,48 @@ async function login(token) {
197
200
  }
198
201
  if (status === "denied") die("authorization denied")
199
202
  if (status === "expired") die("code expired — run `figs login` again")
203
+ if (status === "already_claimed") {
204
+ die("this login was already completed (on another run) — run `figs status` to check; re-run `figs login` if no token was saved")
205
+ }
206
+ if (status === "not_found") die("login code not found — run `figs login` again")
200
207
  // pending → keep polling
201
208
  }
202
209
  die("timed out waiting for approval — run `figs login` again")
203
210
  }
204
211
 
212
+ /**
213
+ * `figs logout` — remove the locally-saved token (`~/.figs/credentials.json`).
214
+ * This only clears *this machine's* copy; the token still exists server-side
215
+ * until you revoke it in Settings. A token supplied via the FIGS_TOKEN env var
216
+ * can't be removed here (unset the env var to fully log out).
217
+ */
218
+ function logout() {
219
+ if (existsSync(globalCreds)) {
220
+ try {
221
+ rmSync(globalCreds)
222
+ } catch (e) {
223
+ die(`could not remove ${globalCreds}: ${e?.message || e}`)
224
+ }
225
+ console.log("figs: ✓ logged out — removed ~/.figs/credentials.json")
226
+ } else {
227
+ console.log("figs: not logged in — no ~/.figs/credentials.json to remove")
228
+ }
229
+ if (process.env.FIGS_TOKEN) {
230
+ console.warn(
231
+ "figs: ! FIGS_TOKEN is still set in your environment — `unset FIGS_TOKEN` to fully log out",
232
+ )
233
+ }
234
+ console.log(
235
+ ` the token still exists server-side — revoke it at ${resolveEndpoint()}/settings`,
236
+ )
237
+ }
238
+
205
239
  /** Show where setup stands — login, workspace, charter. Drives the agent's next step. */
206
240
  async function status() {
207
241
  const token = getToken()
208
242
  const cfg = readJson(join(repoDir, "config.json"), null)
209
243
  const hasAgent = existsSync(join(repoDir, "agent.json"))
244
+ const hasContract = existsSync(join(repoDir, "CONTRACT.md"))
210
245
  const endpoint = resolveEndpoint()
211
246
 
212
247
  let loggedIn = false
@@ -229,6 +264,7 @@ async function status() {
229
264
  workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
230
265
  config: cfg ? { workspaceId: cfg.workspaceId, agentId: cfg.agentId } : null,
231
266
  agentJson: hasAgent,
267
+ contractMd: hasContract,
232
268
  },
233
269
  null,
234
270
  2,
@@ -255,7 +291,13 @@ async function status() {
255
291
  ? cfg.workspaceId
256
292
  : "not initialized — run `figs init --workspace <id>`",
257
293
  )
258
- row("agent.json", hasAgent ? "present" : "missing — author .figs/agent.json")
294
+ row("agent.json", hasAgent ? "present (identity)" : "missing — author .figs/agent.json")
295
+ row(
296
+ "contract",
297
+ hasContract
298
+ ? "present (activity) — follow it"
299
+ : "none yet — Activity is optional, agree it with your user",
300
+ )
259
301
  row("endpoint", endpoint)
260
302
  row("cli", VERSION)
261
303
  }
@@ -286,31 +328,30 @@ function isUuid(s) {
286
328
  }
287
329
 
288
330
  /**
289
- * The local `.figs/GUIDE.md` is a thin, endpoint-relative pointer the full,
290
- * always-current guide + `agent.json` schema is served at `${endpoint}/llms.txt`
291
- * (one source, no frozen copy to drift). Just enough here to act.
331
+ * The local `.figs/GUIDE.md` is a thin orientation breadcrumb (safe to
332
+ * regenerate) — the full, always-current guide + schema is served at
333
+ * `${endpoint}/llms.txt`, and the agent's own standing agreement lives in the
334
+ * precious, agent-authored `.figs/CONTRACT.md` (which `init` never touches).
292
335
  */
293
336
  function guideStub(endpoint) {
294
- return `<!-- Written by \`figs init\`. Full, always-current guide + schema: ${endpoint}/llms.txt -->
295
- # Figs quickstart for this agent
296
-
297
- You are an AI employee. **Figs** is the read-only window your manager uses to see what you're
298
- responsible for, what you've done, and what you need from them. You publish to it; it never
299
- writes back into this repo.
337
+ return `<!-- Written by \`figs init\` (safe to regenerate). Orientation only the full
338
+ guide lives at the URL below; your own contract lives in CONTRACT.md. -->
339
+ # Figs — this folder
300
340
 
301
- **Full guide + \`agent.json\` schema ${endpoint}/llms.txt** read this before authoring your charter.
341
+ This repo publishes to **Figs** (your manager's read-only window into what you do).
302
342
 
303
- Lifecycle:
304
- 1. \`figs login\` a human approves in a browser the token is theirs, never yours.
305
- 2. \`figs init --workspace <slug>\` done wrote config.json (your identity UUID) + this file.
306
- 3. author \`.figs/agent.json\` derive it from this repo's CLAUDE.md / MEMORY.md, then \`figs doctor\`.
307
- 4. \`figs push\` you now appear in the org chart, self-described.
343
+ - **Full guide + schema → ${endpoint}/llms.txt** — read this first.
344
+ - **Your contract → ./CONTRACT.md** what you publish / hold back (you + your user author it).
345
+ - **Identity config.json** (committed) · **charter → agent.json** (you write).
308
346
 
309
- Every run (wire this into your run loop, not a one-off):
310
- append a line to runs.jsonl · record blockers in asks.jsonl · copy reports into artifacts/ · \`figs push\`.
347
+ **Where are you?** You may be reading this at any time don't assume you're starting fresh. Run
348
+ \`figs status\` and check \`.figs/\` before acting:
349
+ • not set up yet → author \`.figs/agent.json\`, then \`figs doctor\` && \`figs push\` to appear (Identity).
350
+ • CONTRACT.md present → follow it; keep publishing what it says.
351
+ • no CONTRACT.md yet → agree with your user what work to surface (Activity) — see ${endpoint}/llms.txt.
311
352
 
312
- Commit config.json + agent.json (identity + charter). runs.jsonl / asks.jsonl / artifacts/ are a
313
- transient outbox gitignored, aggregated remotely. The sync is one-way and never deletes on the server.
353
+ Commit config.json + agent.json + CONTRACT.md + this file. runs.jsonl / asks.jsonl / artifacts/
354
+ are a gitignored outbox. The token is the human's job — never generate one yourself.
314
355
  `
315
356
  }
316
357
 
@@ -355,7 +396,7 @@ async function init() {
355
396
  writeFileSync(
356
397
  giPath,
357
398
  [
358
- "# Figs — commit config.json + agent.json (identity + charter).",
399
+ "# Figs — commit config.json + agent.json + CONTRACT.md + GUIDE.md.",
359
400
  "# Activity is a transient outbox: emitted per run, aggregated remotely.",
360
401
  "runs.jsonl",
361
402
  "asks.jsonl",
@@ -371,8 +412,9 @@ async function init() {
371
412
  `figs: ✓ .figs/config.json + .gitignore + GUIDE.md written (agentId ${agentId})`,
372
413
  )
373
414
  console.log(
374
- ` read .figs/GUIDE.md (full guide: ${endpoint}/llms.txt), author .figs/agent.json, then \`figs doctor\`.`,
415
+ ` Phase 1: author .figs/agent.json (your charter), then \`figs doctor\` && \`figs push\` to appear.`,
375
416
  )
417
+ console.log(` Full guide: ${endpoint}/llms.txt`)
376
418
  }
377
419
 
378
420
  /** Validate the local .figs/ payload against the contract — no write. */
@@ -445,8 +487,10 @@ async function push() {
445
487
  * The spine ingest is JSON-only; artifacts go to a separate endpoint that stores
446
488
  * them content-addressed (an unchanged file is skipped server-side). Content is
447
489
  * sent base64-encoded so any type — html, markdown, text, json, images — survives.
448
- * Files larger than ~3 MB are rejected by the server; compress images if needed.
449
- * Missing files are warned, not fatal.
490
+ * A **server rejection** (auth/size/etc.) is fatal: it prints and exits non-zero
491
+ * so the agent never believes a report published when it didn't (esp. the ~3 MB
492
+ * cap → 413). A **missing local file** is only a warning — that's the agent
493
+ * referencing an artifact it didn't actually produce, not a publish failure.
450
494
  */
451
495
  async function pushArtifacts(base, token, config, runs, asks) {
452
496
  const refNames = (asks ?? []).flatMap((a) =>
@@ -459,10 +503,13 @@ async function pushArtifacts(base, token, config, runs, asks) {
459
503
 
460
504
  let uploaded = 0
461
505
  let unchanged = 0
506
+ let missing = 0
507
+ let failed = 0
462
508
  for (const name of names) {
463
509
  const p = join(repoDir, "artifacts", name)
464
510
  if (!existsSync(p)) {
465
511
  console.warn(`figs: ! artifact missing, skipped: artifacts/${name}`)
512
+ missing++
466
513
  continue
467
514
  }
468
515
  const content = readFileSync(p).toString("base64")
@@ -477,8 +524,13 @@ async function pushArtifacts(base, token, config, runs, asks) {
477
524
  }),
478
525
  })
479
526
  if (!res.ok) {
480
- const t = await res.text()
481
- console.warn(`figs: ! artifact upload failed (${res.status}) ${name}: ${t}`)
527
+ const t = await res.text().catch(() => "")
528
+ const hint =
529
+ res.status === 413 ? " — too large (>3 MB); compress or split it" : ""
530
+ console.error(
531
+ `figs: ✗ artifact upload failed (${res.status}) ${name}${hint}${t ? `: ${t}` : ""}`,
532
+ )
533
+ failed++
482
534
  continue
483
535
  }
484
536
  const body = await res.json().catch(() => ({}))
@@ -486,8 +538,13 @@ async function pushArtifacts(base, token, config, runs, asks) {
486
538
  else uploaded++
487
539
  }
488
540
  console.log(
489
- `figs: ✓ artifacts — ${uploaded} uploaded, ${unchanged} unchanged`,
541
+ `figs: ${failed ? "✗" : ""} artifacts — ${uploaded} uploaded, ${unchanged} unchanged` +
542
+ (missing ? `, ${missing} missing` : "") +
543
+ (failed ? `, ${failed} failed` : ""),
490
544
  )
545
+ // The spine already landed; signal a non-zero exit so an agent's run loop can
546
+ // catch that an artifact the manager needs to read did not publish.
547
+ if (failed) process.exit(1)
491
548
  }
492
549
 
493
550
  function readJsonl(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Figs CLI — publish your AI agent's state to Figs (figs.so). Run by the agent.",
5
5
  "type": "module",
6
6
  "bin": {