@figs-so/cli 0.1.6 → 0.1.8
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 +3 -0
- package/figs.mjs +196 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,5 +31,8 @@ The full, always-current guide + `agent.json` schema is served at **`<endpoint>/
|
|
|
31
31
|
| `figs doctor` | validate `.figs/` against the contract |
|
|
32
32
|
| `figs push` | one-way publish of `.figs/` |
|
|
33
33
|
| `figs version` | print version + check for updates |
|
|
34
|
+
| `figs help [<command>]` | usage (bare `figs` shows it too) |
|
|
35
|
+
|
|
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.
|
|
34
37
|
|
|
35
38
|
Override the endpoint with `FIGS_ENDPOINT` (e.g. `http://localhost:3000` for local dev).
|
package/figs.mjs
CHANGED
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
* figs doctor validate .figs/ against the contract before pushing
|
|
13
13
|
* figs push one-way push the .figs/ spine to the ingest endpoint
|
|
14
14
|
* figs version print the CLI version (and check for updates)
|
|
15
|
+
* figs help [<command>] usage; `-h`/`--help` on any command, `-v` for version
|
|
15
16
|
*
|
|
16
17
|
* Designed to be driven by an agent: non-interactive, clear output, `--json`
|
|
17
|
-
* on read commands, and errors that say what to
|
|
18
|
+
* on read commands, `-h`/`--help`/`help` everywhere, and errors that say what to
|
|
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.
|
|
18
22
|
*
|
|
19
23
|
* Auth is the *user* (a token, configured once per machine). Identity is the
|
|
20
24
|
* *agent* — a UUID generated by `init`, stored in the committed, non-secret
|
|
@@ -33,7 +37,7 @@ import { homedir } from "node:os"
|
|
|
33
37
|
import { join } from "node:path"
|
|
34
38
|
import { randomUUID } from "node:crypto"
|
|
35
39
|
|
|
36
|
-
const VERSION = "0.1.
|
|
40
|
+
const VERSION = "0.1.8"
|
|
37
41
|
// Going-forward default; override with FIGS_ENDPOINT or .figs/config.json endpoint
|
|
38
42
|
// (e.g. FIGS_ENDPOINT=http://localhost:3000 for local dev).
|
|
39
43
|
const DEFAULT_ENDPOINT = "https://app.figs.so"
|
|
@@ -41,8 +45,72 @@ const DEFAULT_ENDPOINT = "https://app.figs.so"
|
|
|
41
45
|
const repoDir = join(process.cwd(), ".figs")
|
|
42
46
|
const globalDir = join(homedir(), ".figs")
|
|
43
47
|
const globalCreds = join(globalDir, "credentials.json")
|
|
44
|
-
const cmd = process.argv[2] ?? "
|
|
48
|
+
const cmd = process.argv[2] ?? "help"
|
|
45
49
|
const JSON_OUT = process.argv.includes("--json")
|
|
50
|
+
const WANTS_HELP = process.argv.slice(2).some((a) => a === "-h" || a === "--help")
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Command registry — single source for dispatch, `figs help`, and per-command
|
|
54
|
+
* flag validation. `flags` lists the flags a command accepts (beyond the global
|
|
55
|
+
* `-h`/`--help`); an unrecognized flag is rejected rather than silently ignored.
|
|
56
|
+
*/
|
|
57
|
+
const COMMANDS = {
|
|
58
|
+
status: {
|
|
59
|
+
args: "[--json]",
|
|
60
|
+
flags: ["--json"],
|
|
61
|
+
desc: "show login / workspace / agent state",
|
|
62
|
+
eg: "figs status --json",
|
|
63
|
+
},
|
|
64
|
+
login: {
|
|
65
|
+
args: "[<token>]",
|
|
66
|
+
flags: [],
|
|
67
|
+
desc: "log in — browser device-flow, or save a pasted token",
|
|
68
|
+
more: [
|
|
69
|
+
"no arg → device flow: a human approves in a browser (you never see the token).",
|
|
70
|
+
"<token> → save a token you already have to ~/.figs/credentials.json.",
|
|
71
|
+
],
|
|
72
|
+
eg: "figs login",
|
|
73
|
+
},
|
|
74
|
+
logout: { args: "", flags: [], desc: "remove the locally-saved token (~/.figs/credentials.json)" },
|
|
75
|
+
workspaces: {
|
|
76
|
+
args: "[--json]",
|
|
77
|
+
flags: ["--json"],
|
|
78
|
+
desc: "list your workspaces (read-only; shows the slug)",
|
|
79
|
+
eg: "figs workspaces",
|
|
80
|
+
},
|
|
81
|
+
init: {
|
|
82
|
+
args: "--workspace <slug-or-id> [--endpoint <url>]",
|
|
83
|
+
flags: ["--workspace", "--endpoint"],
|
|
84
|
+
desc: "set up .figs/ here (identity UUID + config + pointer GUIDE.md)",
|
|
85
|
+
more: [
|
|
86
|
+
"--workspace takes a slug (resolved to its UUID) or a raw UUID — get it from `figs workspaces`.",
|
|
87
|
+
],
|
|
88
|
+
eg: "figs init --workspace acme-corp",
|
|
89
|
+
},
|
|
90
|
+
doctor: { args: "", flags: ["--json"], desc: "validate .figs/ against the live contract before pushing" },
|
|
91
|
+
push: {
|
|
92
|
+
args: "",
|
|
93
|
+
flags: [],
|
|
94
|
+
desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
|
|
95
|
+
more: ["Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected."],
|
|
96
|
+
eg: "figs push",
|
|
97
|
+
},
|
|
98
|
+
version: { args: "", flags: [], desc: "print the CLI version and check for updates" },
|
|
99
|
+
help: { args: "[<command>]", flags: [], desc: "show this help, or detailed help for one command" },
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reject unknown flags for a command (don't silently ignore an agent's typo). */
|
|
103
|
+
function checkFlags(name) {
|
|
104
|
+
const allowed = new Set([...(COMMANDS[name].flags ?? []), "-h", "--help"])
|
|
105
|
+
for (const tok of process.argv.slice(3)) {
|
|
106
|
+
if (tok.startsWith("-") && tok !== "-" && tok !== "--") {
|
|
107
|
+
const f = tok.split("=")[0]
|
|
108
|
+
if (!allowed.has(f)) {
|
|
109
|
+
die(`unknown flag "${f}" for \`figs ${name}\` — run \`figs ${name} --help\``)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
46
114
|
|
|
47
115
|
function die(msg) {
|
|
48
116
|
console.error(`figs: ${msg}`)
|
|
@@ -51,9 +119,14 @@ function die(msg) {
|
|
|
51
119
|
function readJson(path, fallback) {
|
|
52
120
|
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : fallback
|
|
53
121
|
}
|
|
122
|
+
/** Read a flag value — supports both `--name value` and `--name=value`. */
|
|
54
123
|
function flag(name) {
|
|
55
|
-
const
|
|
56
|
-
|
|
124
|
+
const args = process.argv.slice(2)
|
|
125
|
+
for (let i = 0; i < args.length; i++) {
|
|
126
|
+
if (args[i] === name) return args[i + 1]
|
|
127
|
+
if (args[i].startsWith(`${name}=`)) return args[i].slice(name.length + 1)
|
|
128
|
+
}
|
|
129
|
+
return undefined
|
|
57
130
|
}
|
|
58
131
|
function getToken() {
|
|
59
132
|
return process.env.FIGS_TOKEN || readJson(globalCreds, {}).token
|
|
@@ -65,12 +138,28 @@ function resolveEndpoint() {
|
|
|
65
138
|
"",
|
|
66
139
|
)
|
|
67
140
|
}
|
|
141
|
+
const REQUEST_TIMEOUT_MS = 30000
|
|
142
|
+
/** `fetch` with a hard timeout so a hung server never stalls the agent. */
|
|
143
|
+
async function fetchT(url, opts = {}) {
|
|
144
|
+
const ctrl = new AbortController()
|
|
145
|
+
const t = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS)
|
|
146
|
+
try {
|
|
147
|
+
return await fetch(url, { ...opts, signal: ctrl.signal })
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(t)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Human reason for a thrown fetch error (timeout vs. network). */
|
|
153
|
+
function netReason(e) {
|
|
154
|
+
if (e?.name === "AbortError") return `timed out after ${REQUEST_TIMEOUT_MS / 1000}s`
|
|
155
|
+
return e?.cause?.code || e?.code || e?.message || "network error"
|
|
156
|
+
}
|
|
68
157
|
/** Low-level request — returns { ok, status, data }, never throws/exits. */
|
|
69
158
|
async function request(method, path, body, token = getToken()) {
|
|
70
159
|
const base = resolveEndpoint()
|
|
71
160
|
let res
|
|
72
161
|
try {
|
|
73
|
-
res = await
|
|
162
|
+
res = await fetchT(`${base}${path}`, {
|
|
74
163
|
method,
|
|
75
164
|
headers: {
|
|
76
165
|
"content-type": "application/json",
|
|
@@ -79,12 +168,8 @@ async function request(method, path, body, token = getToken()) {
|
|
|
79
168
|
body: body ? JSON.stringify(body) : undefined,
|
|
80
169
|
})
|
|
81
170
|
} catch (e) {
|
|
82
|
-
//
|
|
83
|
-
return {
|
|
84
|
-
ok: false,
|
|
85
|
-
status: 0,
|
|
86
|
-
data: { error: `cannot reach ${base} (${e?.cause?.code || e?.code || e?.message || "network error"})` },
|
|
87
|
-
}
|
|
171
|
+
// Unreachable host / DNS / timeout — degrade, don't crash.
|
|
172
|
+
return { ok: false, status: 0, data: { error: `cannot reach ${base} (${netReason(e)})` } }
|
|
88
173
|
}
|
|
89
174
|
const text = await res.text()
|
|
90
175
|
let data
|
|
@@ -103,12 +188,20 @@ async function api(method, path, body) {
|
|
|
103
188
|
return r.data
|
|
104
189
|
}
|
|
105
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Compare two semver strings → -1 | 0 | 1, or **null** if either is unparseable.
|
|
193
|
+
* Returning null (rather than guessing) lets the caller skip the check instead of
|
|
194
|
+
* failing closed on a malformed server-provided version — a bad `min` must never
|
|
195
|
+
* lock an agent out of pushing.
|
|
196
|
+
*/
|
|
106
197
|
function cmpSemver(a, b) {
|
|
107
|
-
const pa = String(a).split(".").map(
|
|
108
|
-
const pb = String(b).split(".").map(
|
|
198
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10))
|
|
199
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10))
|
|
109
200
|
for (let i = 0; i < 3; i++) {
|
|
110
|
-
const
|
|
111
|
-
|
|
201
|
+
const x = pa[i] ?? 0
|
|
202
|
+
const y = pb[i] ?? 0
|
|
203
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return null
|
|
204
|
+
if (x !== y) return x < y ? -1 : 1
|
|
112
205
|
}
|
|
113
206
|
return 0
|
|
114
207
|
}
|
|
@@ -135,29 +228,64 @@ async function checkVersion({ force = false, hardFail = false } = {}) {
|
|
|
135
228
|
}
|
|
136
229
|
const min = info?.cli?.min
|
|
137
230
|
const latest = info?.cli?.latest
|
|
138
|
-
|
|
231
|
+
// cmpSemver returns null on an unparseable version → skip (never fail closed).
|
|
232
|
+
if (min && cmpSemver(VERSION, min) === -1) {
|
|
139
233
|
const msg = `figs CLI ${VERSION} is below the minimum ${min} — upgrade: npx @figs-so/cli@latest`
|
|
140
234
|
if (hardFail) die(msg)
|
|
141
235
|
console.warn(`figs: ! ${msg}`)
|
|
142
|
-
} else if (latest && cmpSemver(VERSION, latest)
|
|
236
|
+
} else if (latest && cmpSemver(VERSION, latest) === -1) {
|
|
143
237
|
console.warn(`figs: a newer CLI is available (${latest}) — npx @figs-so/cli@latest`)
|
|
144
238
|
}
|
|
145
239
|
}
|
|
146
240
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
241
|
+
/** `figs help [cmd]` — top-level usage, or one command's detail. */
|
|
242
|
+
function printHelp(name) {
|
|
243
|
+
const pad = 36
|
|
244
|
+
if (name && name !== "-h" && name !== "--help") {
|
|
245
|
+
const c = COMMANDS[name]
|
|
246
|
+
if (!c) {
|
|
247
|
+
console.log(`figs: no such command "${name}"\n`)
|
|
248
|
+
return printHelp()
|
|
249
|
+
}
|
|
250
|
+
console.log(`Usage: figs ${name}${c.args ? " " + c.args : ""}\n`)
|
|
251
|
+
console.log(` ${c.desc}`)
|
|
252
|
+
if (c.more) for (const line of c.more) console.log(` ${line}`)
|
|
253
|
+
if (c.eg) console.log(`\n e.g. ${c.eg}`)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
console.log("figs — publish your AI agent's state to Figs (https://figs.so)\n")
|
|
257
|
+
console.log("Usage: figs <command> [options]\n")
|
|
258
|
+
console.log("Commands:")
|
|
259
|
+
for (const [n, c] of Object.entries(COMMANDS)) {
|
|
260
|
+
console.log(` ${`${n} ${c.args}`.trim().padEnd(pad)} ${c.desc}`)
|
|
261
|
+
}
|
|
262
|
+
console.log("\nGlobal flags:")
|
|
263
|
+
console.log(` ${"-h, --help".padEnd(pad)} show help (or \`figs help <command>\`)`)
|
|
264
|
+
console.log(` ${"-v, --version".padEnd(pad)} print the CLI version`)
|
|
265
|
+
console.log("\nEnvironment:")
|
|
266
|
+
console.log(` ${"FIGS_ENDPOINT".padEnd(pad)} override the API endpoint (e.g. http://localhost:3000)`)
|
|
267
|
+
console.log(` ${"FIGS_TOKEN".padEnd(pad)} use this token instead of ~/.figs/credentials.json`)
|
|
268
|
+
console.log(`\nEndpoint: ${resolveEndpoint()}`)
|
|
269
|
+
console.log(`Guide: ${resolveEndpoint()}/llms.txt`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (cmd === "help" || cmd === "-h" || cmd === "--help") printHelp(process.argv[3])
|
|
273
|
+
else if (cmd === "version" || cmd === "--version" || cmd === "-v" || cmd === "-V") {
|
|
155
274
|
console.log(VERSION)
|
|
156
275
|
await checkVersion({ force: true })
|
|
276
|
+
} else if (WANTS_HELP) printHelp(cmd)
|
|
277
|
+
else if (COMMANDS[cmd]) {
|
|
278
|
+
checkFlags(cmd) // reject unknown flags before running
|
|
279
|
+
if (cmd === "login") await login(process.argv[3])
|
|
280
|
+
else if (cmd === "logout") logout()
|
|
281
|
+
else if (cmd === "status") await status()
|
|
282
|
+
else if (cmd === "workspaces") await workspaces()
|
|
283
|
+
else if (cmd === "init") await init()
|
|
284
|
+
else if (cmd === "doctor") await doctor()
|
|
285
|
+
else if (cmd === "push") await push()
|
|
157
286
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
287
|
+
console.error(`figs: unknown command "${cmd}" — run \`figs help\` for usage`)
|
|
288
|
+
process.exit(1)
|
|
161
289
|
}
|
|
162
290
|
|
|
163
291
|
function sleep(ms) {
|
|
@@ -468,11 +596,16 @@ async function push() {
|
|
|
468
596
|
const asks = foldById(readJsonl("asks.jsonl"))
|
|
469
597
|
|
|
470
598
|
const base = endpoint.replace(/\/+$/, "")
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
599
|
+
let res
|
|
600
|
+
try {
|
|
601
|
+
res = await fetchT(`${base}/api/ingest`, {
|
|
602
|
+
method: "POST",
|
|
603
|
+
headers: { "content-type": "application/json", "x-figs-token": token },
|
|
604
|
+
body: JSON.stringify({ workspaceId: config.workspaceId, agent, runs, asks }),
|
|
605
|
+
})
|
|
606
|
+
} catch (e) {
|
|
607
|
+
die(`push failed — cannot reach ${base} (${netReason(e)})`)
|
|
608
|
+
}
|
|
476
609
|
const text = await res.text()
|
|
477
610
|
if (!res.ok) die(`push failed (${res.status}): ${text}`)
|
|
478
611
|
console.log(
|
|
@@ -513,16 +646,23 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
513
646
|
continue
|
|
514
647
|
}
|
|
515
648
|
const content = readFileSync(p).toString("base64")
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
649
|
+
let res
|
|
650
|
+
try {
|
|
651
|
+
res = await fetchT(`${base}/api/artifacts/upload`, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers: { "content-type": "application/json", "x-figs-token": token },
|
|
654
|
+
body: JSON.stringify({
|
|
655
|
+
workspaceId: config.workspaceId,
|
|
656
|
+
agentId: config.agentId,
|
|
657
|
+
name,
|
|
658
|
+
content,
|
|
659
|
+
}),
|
|
660
|
+
})
|
|
661
|
+
} catch (e) {
|
|
662
|
+
console.error(`figs: ✗ artifact upload failed ${name}: ${netReason(e)}`)
|
|
663
|
+
failed++
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
526
666
|
if (!res.ok) {
|
|
527
667
|
const t = await res.text().catch(() => "")
|
|
528
668
|
const hint =
|
|
@@ -550,10 +690,19 @@ async function pushArtifacts(base, token, config, runs, asks) {
|
|
|
550
690
|
function readJsonl(name) {
|
|
551
691
|
const p = join(repoDir, name)
|
|
552
692
|
if (!existsSync(p)) return []
|
|
553
|
-
|
|
693
|
+
const out = []
|
|
694
|
+
readFileSync(p, "utf8")
|
|
554
695
|
.split("\n")
|
|
555
|
-
.
|
|
556
|
-
|
|
696
|
+
.forEach((line, i) => {
|
|
697
|
+
const s = line.trim()
|
|
698
|
+
if (!s) return
|
|
699
|
+
try {
|
|
700
|
+
out.push(JSON.parse(s))
|
|
701
|
+
} catch {
|
|
702
|
+
die(`malformed JSON in .figs/${name} line ${i + 1}: ${s.slice(0, 80)}`)
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
return out
|
|
557
706
|
}
|
|
558
707
|
|
|
559
708
|
/** Fold append-only records by id — latest line wins. */
|