@figs-so/cli 0.1.7 → 0.1.9

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 -1
  2. package/figs.mjs +163 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,6 +33,6 @@ The full, always-current guide + `agent.json` schema is served at **`<endpoint>/
33
33
  | `figs version` | print version + check for updates |
34
34
  | `figs help [<command>]` | usage (bare `figs` shows it too) |
35
35
 
36
- Global: `-h` / `--help` on any command (or `figs help <command>`), `-v` / `--version` for the version. Unknown commands exit non-zero.
36
+ Global: `-h` / `--help` on any command (or `figs help <command>`), `-v` / `--version` for the version. Unknown commands **and unknown flags** exit non-zero (no silent no-ops). Flags accept `--name value` or `--name=value`. Network calls time out after 30s.
37
37
 
38
38
  Override the endpoint with `FIGS_ENDPOINT` (e.g. `http://localhost:3000` for local dev).
package/figs.mjs CHANGED
@@ -16,7 +16,9 @@
16
16
  *
17
17
  * Designed to be driven by an agent: non-interactive, clear output, `--json`
18
18
  * on read commands, `-h`/`--help`/`help` everywhere, and errors that say what to
19
- * do next. Bare `figs` prints help; unknown commands exit non-zero.
19
+ * do next. Bare `figs` prints help; unknown commands AND unknown flags exit
20
+ * non-zero (no silent no-ops). Flags accept `--name value` or `--name=value`.
21
+ * Network calls time out (30s) instead of hanging the agent.
20
22
  *
21
23
  * Auth is the *user* (a token, configured once per machine). Identity is the
22
24
  * *agent* — a UUID generated by `init`, stored in the committed, non-secret
@@ -25,6 +27,7 @@
25
27
  */
26
28
 
27
29
  import {
30
+ chmodSync,
28
31
  existsSync,
29
32
  mkdirSync,
30
33
  readFileSync,
@@ -35,7 +38,7 @@ import { homedir } from "node:os"
35
38
  import { join } from "node:path"
36
39
  import { randomUUID } from "node:crypto"
37
40
 
38
- const VERSION = "0.1.7"
41
+ const VERSION = "0.1.9"
39
42
  // Going-forward default; override with FIGS_ENDPOINT or .figs/config.json endpoint
40
43
  // (e.g. FIGS_ENDPOINT=http://localhost:3000 for local dev).
41
44
  const DEFAULT_ENDPOINT = "https://app.figs.so"
@@ -47,34 +50,67 @@ const cmd = process.argv[2] ?? "help"
47
50
  const JSON_OUT = process.argv.includes("--json")
48
51
  const WANTS_HELP = process.argv.slice(2).some((a) => a === "-h" || a === "--help")
49
52
 
50
- /** Command registry — single source for dispatch + `figs help`. */
53
+ /**
54
+ * Command registry — single source for dispatch, `figs help`, and per-command
55
+ * flag validation. `flags` lists the flags a command accepts (beyond the global
56
+ * `-h`/`--help`); an unrecognized flag is rejected rather than silently ignored.
57
+ */
51
58
  const COMMANDS = {
52
- status: { args: "[--json]", desc: "show login / workspace / agent state" },
59
+ status: {
60
+ args: "[--json]",
61
+ flags: ["--json"],
62
+ desc: "show login / workspace / agent state",
63
+ eg: "figs status --json",
64
+ },
53
65
  login: {
54
66
  args: "[<token>]",
67
+ flags: [],
55
68
  desc: "log in — browser device-flow, or save a pasted token",
56
69
  more: [
57
70
  "no arg → device flow: a human approves in a browser (you never see the token).",
58
71
  "<token> → save a token you already have to ~/.figs/credentials.json.",
59
72
  ],
73
+ eg: "figs login",
74
+ },
75
+ logout: { args: "", flags: [], desc: "remove the locally-saved token (~/.figs/credentials.json)" },
76
+ workspaces: {
77
+ args: "[--json]",
78
+ flags: ["--json"],
79
+ desc: "list your workspaces (read-only; shows the slug)",
80
+ eg: "figs workspaces",
60
81
  },
61
- logout: { args: "", desc: "remove the locally-saved token (~/.figs/credentials.json)" },
62
- workspaces: { args: "[--json]", desc: "list your workspaces (read-only; shows the slug)" },
63
82
  init: {
64
83
  args: "--workspace <slug-or-id> [--endpoint <url>]",
84
+ flags: ["--workspace", "--endpoint"],
65
85
  desc: "set up .figs/ here (identity UUID + config + pointer GUIDE.md)",
66
86
  more: [
67
87
  "--workspace takes a slug (resolved to its UUID) or a raw UUID — get it from `figs workspaces`.",
68
88
  ],
89
+ eg: "figs init --workspace acme-corp",
69
90
  },
70
- doctor: { args: "", desc: "validate .figs/ against the live contract before pushing" },
91
+ doctor: { args: "", flags: ["--json"], desc: "validate .figs/ against the live contract before pushing" },
71
92
  push: {
72
93
  args: "",
94
+ flags: [],
73
95
  desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
74
96
  more: ["Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected."],
97
+ eg: "figs push",
75
98
  },
76
- version: { args: "", desc: "print the CLI version and check for updates" },
77
- help: { args: "[<command>]", desc: "show this help, or detailed help for one command" },
99
+ version: { args: "", flags: [], desc: "print the CLI version and check for updates" },
100
+ help: { args: "[<command>]", flags: [], desc: "show this help, or detailed help for one command" },
101
+ }
102
+
103
+ /** Reject unknown flags for a command (don't silently ignore an agent's typo). */
104
+ function checkFlags(name) {
105
+ const allowed = new Set([...(COMMANDS[name].flags ?? []), "-h", "--help"])
106
+ for (const tok of process.argv.slice(3)) {
107
+ if (tok.startsWith("-") && tok !== "-" && tok !== "--") {
108
+ const f = tok.split("=")[0]
109
+ if (!allowed.has(f)) {
110
+ die(`unknown flag "${f}" for \`figs ${name}\` — run \`figs ${name} --help\``)
111
+ }
112
+ }
113
+ }
78
114
  }
79
115
 
80
116
  function die(msg) {
@@ -84,9 +120,14 @@ function die(msg) {
84
120
  function readJson(path, fallback) {
85
121
  return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback
86
122
  }
123
+ /** Read a flag value — supports both `--name value` and `--name=value`. */
87
124
  function flag(name) {
88
- const i = process.argv.indexOf(name)
89
- return i >= 0 ? process.argv[i + 1] : undefined
125
+ const args = process.argv.slice(2)
126
+ for (let i = 0; i < args.length; i++) {
127
+ if (args[i] === name) return args[i + 1]
128
+ if (args[i].startsWith(`${name}=`)) return args[i].slice(name.length + 1)
129
+ }
130
+ return undefined
90
131
  }
91
132
  function getToken() {
92
133
  return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
@@ -98,12 +139,28 @@ function resolveEndpoint() {
98
139
  "",
99
140
  )
100
141
  }
142
+ const REQUEST_TIMEOUT_MS = 30000
143
+ /** `fetch` with a hard timeout so a hung server never stalls the agent. */
144
+ async function fetchT(url, opts = {}) {
145
+ const ctrl = new AbortController()
146
+ const t = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS)
147
+ try {
148
+ return await fetch(url, { ...opts, signal: ctrl.signal })
149
+ } finally {
150
+ clearTimeout(t)
151
+ }
152
+ }
153
+ /** Human reason for a thrown fetch error (timeout vs. network). */
154
+ function netReason(e) {
155
+ if (e?.name === "AbortError") return `timed out after ${REQUEST_TIMEOUT_MS / 1000}s`
156
+ return e?.cause?.code || e?.code || e?.message || "network error"
157
+ }
101
158
  /** Low-level request — returns { ok, status, data }, never throws/exits. */
102
159
  async function request(method, path, body, token = getToken()) {
103
160
  const base = resolveEndpoint()
104
161
  let res
105
162
  try {
106
- res = await fetch(`${base}${path}`, {
163
+ res = await fetchT(`${base}${path}`, {
107
164
  method,
108
165
  headers: {
109
166
  "content-type": "application/json",
@@ -112,12 +169,8 @@ async function request(method, path, body, token = getToken()) {
112
169
  body: body ? JSON.stringify(body) : undefined,
113
170
  })
114
171
  } catch (e) {
115
- // Network error (unreachable host, DNS, timeout) — degrade, don't crash.
116
- return {
117
- ok: false,
118
- status: 0,
119
- data: { error: `cannot reach ${base} (${e?.cause?.code || e?.code || e?.message || "network error"})` },
120
- }
172
+ // Unreachable host / DNS / timeout — degrade, don't crash.
173
+ return { ok: false, status: 0, data: { error: `cannot reach ${base} (${netReason(e)})` } }
121
174
  }
122
175
  const text = await res.text()
123
176
  let data
@@ -136,12 +189,20 @@ async function api(method, path, body) {
136
189
  return r.data
137
190
  }
138
191
 
192
+ /**
193
+ * Compare two semver strings → -1 | 0 | 1, or **null** if either is unparseable.
194
+ * Returning null (rather than guessing) lets the caller skip the check instead of
195
+ * failing closed on a malformed server-provided version — a bad `min` must never
196
+ * lock an agent out of pushing.
197
+ */
139
198
  function cmpSemver(a, b) {
140
- const pa = String(a).split(".").map(Number)
141
- const pb = String(b).split(".").map(Number)
199
+ const pa = String(a).split(".").map((n) => parseInt(n, 10))
200
+ const pb = String(b).split(".").map((n) => parseInt(n, 10))
142
201
  for (let i = 0; i < 3; i++) {
143
- const d = (pa[i] || 0) - (pb[i] || 0)
144
- if (d) return d < 0 ? -1 : 1
202
+ const x = pa[i] ?? 0
203
+ const y = pb[i] ?? 0
204
+ if (Number.isNaN(x) || Number.isNaN(y)) return null
205
+ if (x !== y) return x < y ? -1 : 1
145
206
  }
146
207
  return 0
147
208
  }
@@ -168,11 +229,12 @@ async function checkVersion({ force = false, hardFail = false } = {}) {
168
229
  }
169
230
  const min = info?.cli?.min
170
231
  const latest = info?.cli?.latest
171
- if (min && cmpSemver(VERSION, min) < 0) {
232
+ // cmpSemver returns null on an unparseable version → skip (never fail closed).
233
+ if (min && cmpSemver(VERSION, min) === -1) {
172
234
  const msg = `figs CLI ${VERSION} is below the minimum ${min} — upgrade: npx @figs-so/cli@latest`
173
235
  if (hardFail) die(msg)
174
236
  console.warn(`figs: ! ${msg}`)
175
- } else if (latest && cmpSemver(VERSION, latest) < 0) {
237
+ } else if (latest && cmpSemver(VERSION, latest) === -1) {
176
238
  console.warn(`figs: a newer CLI is available (${latest}) — npx @figs-so/cli@latest`)
177
239
  }
178
240
  }
@@ -189,6 +251,7 @@ function printHelp(name) {
189
251
  console.log(`Usage: figs ${name}${c.args ? " " + c.args : ""}\n`)
190
252
  console.log(` ${c.desc}`)
191
253
  if (c.more) for (const line of c.more) console.log(` ${line}`)
254
+ if (c.eg) console.log(`\n e.g. ${c.eg}`)
192
255
  return
193
256
  }
194
257
  console.log("figs — publish your AI agent's state to Figs (https://figs.so)\n")
@@ -212,14 +275,16 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v" || cmd === "-V
212
275
  console.log(VERSION)
213
276
  await checkVersion({ force: true })
214
277
  } else if (WANTS_HELP) printHelp(cmd)
215
- else if (cmd === "login") await login(process.argv[3])
216
- else if (cmd === "logout") logout()
217
- else if (cmd === "status") await status()
218
- else if (cmd === "workspaces") await workspaces()
219
- else if (cmd === "init") await init()
220
- else if (cmd === "doctor") await doctor()
221
- else if (cmd === "push") await push()
222
- else {
278
+ else if (COMMANDS[cmd]) {
279
+ checkFlags(cmd) // reject unknown flags before running
280
+ if (cmd === "login") await login(process.argv[3])
281
+ else if (cmd === "logout") logout()
282
+ else if (cmd === "status") await status()
283
+ else if (cmd === "workspaces") await workspaces()
284
+ else if (cmd === "init") await init()
285
+ else if (cmd === "doctor") await doctor()
286
+ else if (cmd === "push") await push()
287
+ } else {
223
288
  console.error(`figs: unknown command "${cmd}" — run \`figs help\` for usage`)
224
289
  process.exit(1)
225
290
  }
@@ -228,8 +293,17 @@ function sleep(ms) {
228
293
  return new Promise((r) => setTimeout(r, ms))
229
294
  }
230
295
  function saveToken(token) {
231
- mkdirSync(globalDir, { recursive: true })
232
- writeFileSync(globalCreds, JSON.stringify({ token }, null, 2) + "\n")
296
+ // A bearer token must not be group/other-readable: dir 0700, file 0600.
297
+ mkdirSync(globalDir, { recursive: true, mode: 0o700 })
298
+ writeFileSync(globalCreds, JSON.stringify({ token }, null, 2) + "\n", { mode: 0o600 })
299
+ // Enforce perms even if the dir/file pre-existed with looser modes (mode on
300
+ // write only applies at creation).
301
+ try {
302
+ chmodSync(globalDir, 0o700)
303
+ chmodSync(globalCreds, 0o600)
304
+ } catch {
305
+ /* best-effort — non-POSIX filesystems */
306
+ }
233
307
  }
234
308
 
235
309
  /**
@@ -310,12 +384,16 @@ async function status() {
310
384
 
311
385
  let loggedIn = false
312
386
  let list = null
387
+ let account = null
313
388
  let unreachable = false
314
389
  if (token) {
315
390
  const r = await request("GET", "/api/workspaces", null, token)
316
391
  loggedIn = r.ok
317
392
  unreachable = r.status === 0
318
- if (r.ok) list = r.data.workspaces ?? []
393
+ if (r.ok) {
394
+ list = r.data.workspaces ?? []
395
+ account = r.data.user ?? null // null on a pre-account server
396
+ }
319
397
  }
320
398
 
321
399
  if (JSON_OUT) {
@@ -325,6 +403,9 @@ async function status() {
325
403
  version: VERSION,
326
404
  endpoint,
327
405
  loggedIn,
406
+ account: account
407
+ ? { id: account.id, email: account.email, name: account.name }
408
+ : null,
328
409
  workspaces: list?.map((w) => ({ id: w.id, name: w.name, role: w.role })),
329
410
  config: cfg ? { workspaceId: cfg.workspaceId, agentId: cfg.agentId } : null,
330
411
  agentJson: hasAgent,
@@ -349,6 +430,14 @@ async function status() {
349
430
  ? "token invalid — run `figs login`"
350
431
  : "no — run `figs login`",
351
432
  )
433
+ if (loggedIn) {
434
+ row(
435
+ "account",
436
+ account?.email
437
+ ? `${account.email}${account.name ? ` (${account.name})` : ""}`
438
+ : "—",
439
+ )
440
+ }
352
441
  row(
353
442
  "workspace",
354
443
  cfg?.workspaceId
@@ -532,11 +621,16 @@ async function push() {
532
621
  const asks = foldById(readJsonl("asks.jsonl"))
533
622
 
534
623
  const base = endpoint.replace(/\/+$/, "")
535
- const res = await fetch(`${base}/api/ingest`, {
536
- method: "POST",
537
- headers: { "content-type": "application/json", "x-figs-token": token },
538
- body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
539
- })
624
+ let res
625
+ try {
626
+ res = await fetchT(`${base}/api/ingest`, {
627
+ method: "POST",
628
+ headers: { "content-type": "application/json", "x-figs-token": token },
629
+ body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
630
+ })
631
+ } catch (e) {
632
+ die(`push failed — cannot reach ${base} (${netReason(e)})`)
633
+ }
540
634
  const text = await res.text()
541
635
  if (!res.ok) die(`push failed (${res.status}): ${text}`)
542
636
  console.log(
@@ -577,16 +671,23 @@ async function pushArtifacts(base, token, config, runs, asks) {
577
671
  continue
578
672
  }
579
673
  const content = readFileSync(p).toString("base64")
580
- const res = await fetch(`${base}/api/artifacts/upload`, {
581
- method: "POST",
582
- headers: { "content-type": "application/json", "x-figs-token": token },
583
- body: JSON.stringify({
584
- workspaceId: config.workspaceId,
585
- agentId: config.agentId,
586
- name,
587
- content,
588
- }),
589
- })
674
+ let res
675
+ try {
676
+ res = await fetchT(`${base}/api/artifacts/upload`, {
677
+ method: "POST",
678
+ headers: { "content-type": "application/json", "x-figs-token": token },
679
+ body: JSON.stringify({
680
+ workspaceId: config.workspaceId,
681
+ agentId: config.agentId,
682
+ name,
683
+ content,
684
+ }),
685
+ })
686
+ } catch (e) {
687
+ console.error(`figs: ✗ artifact upload failed ${name}: ${netReason(e)}`)
688
+ failed++
689
+ continue
690
+ }
590
691
  if (!res.ok) {
591
692
  const t = await res.text().catch(() => "")
592
693
  const hint =
@@ -614,10 +715,19 @@ async function pushArtifacts(base, token, config, runs, asks) {
614
715
  function readJsonl(name) {
615
716
  const p = join(repoDir, name)
616
717
  if (!existsSync(p)) return []
617
- return readFileSync(p, "utf8")
718
+ const out = []
719
+ readFileSync(p, "utf8")
618
720
  .split("\n")
619
- .filter((l) => l.trim())
620
- .map((l) => JSON.parse(l))
721
+ .forEach((line, i) => {
722
+ const s = line.trim()
723
+ if (!s) return
724
+ try {
725
+ out.push(JSON.parse(s))
726
+ } catch {
727
+ die(`malformed JSON in .figs/${name} line ${i + 1}: ${s.slice(0, 80)}`)
728
+ }
729
+ })
730
+ return out
621
731
  }
622
732
 
623
733
  /** Fold append-only records by id — latest line wins. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figs-so/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {