@todoforai/tfa-surface 0.1.0 → 0.1.2

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 (2) hide show
  1. package/dist/cli.js +153 -40
  2. package/package.json +12 -4
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
2
4
 
3
5
  // src/cli.ts
4
6
  import { parseArgs } from "node:util";
@@ -18,10 +20,15 @@ function normalizeApiUrl(url) {
18
20
  function credentialsPath() {
19
21
  return platform() === "win32" ? join(homedir(), "AppData", "Roaming", "todoforai", "credentials.json") : platform() === "darwin" ? join(homedir(), "Library", "Application Support", "todoforai", "credentials.json") : join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "todoforai", "credentials.json");
20
22
  }
21
- function credentialsPaths() {
22
- return [credentialsPath()];
23
+ function read() {
24
+ try {
25
+ return JSON.parse(readFileSync(credentialsPath(), "utf-8"));
26
+ } catch {
27
+ return {};
28
+ }
23
29
  }
24
- function writeSecure(p, data) {
30
+ function writeSecure(data) {
31
+ const p = credentialsPath();
25
32
  mkdirSync(dirname(p), { recursive: true });
26
33
  writeFileSync(p, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 384 });
27
34
  try {
@@ -29,31 +36,29 @@ function writeSecure(p, data) {
29
36
  } catch {}
30
37
  }
31
38
  function readStoredAuth(apiUrl) {
32
- for (const p of credentialsPaths()) {
33
- try {
34
- const c = JSON.parse(readFileSync(p, "utf-8"));
35
- const apiToken = c[apiUrl] || c.apiToken || c.apiKey || "";
36
- const host = c.backendHost || "";
37
- const storedApiUrl = typeof c.apiUrl === "string" && c.apiUrl ? c.apiUrl : host ? `${host === "localhost" || host === "127.0.0.1" ? "http" : "https"}://${host}` : undefined;
38
- return {
39
- apiToken,
40
- apiUrl: storedApiUrl,
41
- deviceId: typeof c.deviceId === "string" ? c.deviceId : undefined,
42
- deviceSecret: typeof c.deviceSecret === "string" ? c.deviceSecret : undefined
43
- };
44
- } catch {}
45
- }
46
- return { apiToken: "" };
39
+ const c = read();
40
+ const host = c.backendHost || "";
41
+ const storedApiUrl = typeof c.apiUrl === "string" && c.apiUrl ? c.apiUrl : host ? `${host === "localhost" || host === "127.0.0.1" ? "http" : "https"}://${host}` : undefined;
42
+ const dstIsForThisUrl = !storedApiUrl || normalizeApiUrl(storedApiUrl) === normalizeApiUrl(apiUrl);
43
+ const isDst = typeof c.apiToken === "string" && c.apiToken.startsWith("dst_");
44
+ const dst = dstIsForThisUrl && isDst ? c.apiToken : "";
45
+ const fallbackApiToken = isDst && !dstIsForThisUrl ? "" : c.apiToken;
46
+ const apiToken = dst || c[apiUrl] || fallbackApiToken || c.apiKey || "";
47
+ return {
48
+ apiToken: typeof apiToken === "string" ? apiToken : "",
49
+ apiUrl: storedApiUrl,
50
+ deviceId: typeof c.deviceId === "string" ? c.deviceId : undefined,
51
+ deviceSecret: typeof c.deviceSecret === "string" ? c.deviceSecret : undefined
52
+ };
47
53
  }
48
54
  function persistApiToken(token) {
49
- for (const p of credentialsPaths()) {
50
- try {
51
- const c = JSON.parse(readFileSync(p, "utf-8"));
52
- c.apiToken = token;
53
- writeSecure(p, c);
54
- return;
55
- } catch {}
56
- }
55
+ const c = read();
56
+ if (!Object.keys(c).length)
57
+ return;
58
+ c.apiToken = token;
59
+ try {
60
+ writeSecure(c);
61
+ } catch {}
57
62
  }
58
63
  async function refreshDeviceToken(opts = {}) {
59
64
  const envUrl = process.env.TODOFORAI_API_URL || process.env.API_URL;
@@ -62,6 +67,8 @@ async function refreshDeviceToken(opts = {}) {
62
67
  const apiUrl = normalizeApiUrl(opts.apiUrl || envUrl || stored.apiUrl || DEFAULT_API_URL);
63
68
  if (!stored.deviceId || !stored.deviceSecret)
64
69
  return "";
70
+ if (stored.apiUrl && normalizeApiUrl(stored.apiUrl) !== apiUrl)
71
+ return "";
65
72
  try {
66
73
  const res = await fetch(`${apiUrl}/api/v1/cli/device/token`, {
67
74
  method: "POST",
@@ -82,9 +89,13 @@ async function refreshDeviceToken(opts = {}) {
82
89
  async function fetchWithRenew(url, init, opts) {
83
90
  const header = opts.header ?? "x-api-key";
84
91
  const renewOn = opts.renewOn ?? [401];
85
- const send = (token) => fetch(url, { ...init, headers: { ...init.headers, [header]: token } });
86
- let res = await send(opts.token);
87
- if (renewOn.includes(res.status) && opts.token.startsWith("dst_")) {
92
+ const send = (token) => {
93
+ const headers = new Headers(init.headers);
94
+ headers.set(header, token);
95
+ return fetch(url, { ...init, headers });
96
+ };
97
+ const res = await send(opts.token);
98
+ if (renewOn.includes(res.status)) {
88
99
  const fresh = await refreshDeviceToken({ apiUrl: opts.apiUrl });
89
100
  if (fresh && fresh !== opts.token)
90
101
  return { res: await send(fresh), token: fresh };
@@ -96,7 +107,8 @@ function resolveToken(opts = {}) {
96
107
  const initialApiUrl = normalizeApiUrl(opts.apiUrl || envUrl || DEFAULT_API_URL);
97
108
  const stored = readStoredAuth(initialApiUrl);
98
109
  const apiUrl = normalizeApiUrl(opts.apiUrl || envUrl || stored.apiUrl || DEFAULT_API_URL);
99
- const apiToken = opts.apiToken || process.env.TODOFORAI_API_TOKEN || stored.apiToken || "";
110
+ const finalStored = apiUrl === initialApiUrl ? stored : readStoredAuth(apiUrl);
111
+ const apiToken = opts.apiToken || process.env.TODOFORAI_API_TOKEN || finalStored.apiToken || "";
100
112
  if (!apiToken)
101
113
  throw new Error("TODOFORAI_API_TOKEN not set and no apiToken found in ~/.config/todoforai/credentials.json (run `<cli> login` or `todoforai-bridge login`, or pass --api-token)");
102
114
  return { apiUrl, apiToken };
@@ -104,8 +116,8 @@ function resolveToken(opts = {}) {
104
116
  // package.json
105
117
  var package_default = {
106
118
  name: "@todoforai/tfa-surface",
107
- version: "0.1.0",
108
- description: "TODOforAI surface CLI — see and drive what is shown in the user's live UI (describe, todo/panel views, surface render, controls). Replaces tfa-frontend. Reuses bridge credentials.",
119
+ version: "0.1.2",
120
+ description: "TODOforAI surface CLI — see and drive what is shown in the user's live UI (describe, todo/panel views, renderer cards, surface render, controls). Replaces tfa-frontend. Reuses bridge credentials.",
109
121
  type: "module",
110
122
  bin: {
111
123
  "tfa-surface": "dist/cli.js"
@@ -118,10 +130,18 @@ var package_default = {
118
130
  "@todoforai/tfa-subagent": "^0.1.9",
119
131
  "@types/node": "^20.11.0"
120
132
  },
121
- keywords: ["cli", "frontend", "surfaces", "ui-control", "todoforai"],
133
+ keywords: [
134
+ "cli",
135
+ "frontend",
136
+ "surfaces",
137
+ "ui-control",
138
+ "todoforai"
139
+ ],
122
140
  author: "TODO for AI",
123
141
  license: "MIT",
124
- files: ["dist"]
142
+ files: [
143
+ "dist"
144
+ ]
125
145
  };
126
146
 
127
147
  // src/cli.ts
@@ -138,7 +158,8 @@ Usage:
138
158
  Discovery:
139
159
  describe What's open in the target frontend:
140
160
  todos, focus, panel surfaces, sidebar,
141
- embedded-browser CDP URL. YAML; --json
161
+ embedded-browser CDP URL, show/card
162
+ instances in loaded chats. YAML; --json
142
163
  for the raw structure.
143
164
 
144
165
  Todos (views):
@@ -149,9 +170,19 @@ Todos (views):
149
170
 
150
171
  Panels (surfaces inside a todo; --todo defaults to $TODOFORAI_TODO_ID):
151
172
  panel open <surface> [--todo <id>] [--url <url>]
152
- surface: changes|preview|browser|terminal
173
+ surface: changes|agents|preview|browser|terminal
153
174
  panel close [--todo <id>]
154
175
 
176
+ Renderer cards (data plane — works headless, no frontend needed;
177
+ --project defaults to $TODOFORAI_PROJECT_ID):
178
+ card add <name> [file.html] [--title <t>] [--description <d>] [--project <id>]
179
+ Create/update a project card (stdin when
180
+ no file). Re-using a name updates every
181
+ mounted instance in place.
182
+ card list [--project <id>] List the project's cards.
183
+ card get <name> [--project <id>] Print a card's HTML (--json: metadata).
184
+ card rm <name> [--project <id>] Delete a card.
185
+
155
186
  Content (inherited from tfa-frontend, broadcast to all tabs):
156
187
  show <surface> <target> [markdown] Render markdown (stdin when omitted).
157
188
  clear <surface> <target> Clear a surface.
@@ -170,6 +201,7 @@ Options:
170
201
 
171
202
  Examples:
172
203
  tfa-surface describe
204
+ tfa-surface card add kpi-tile dashboards/kpi.html --title "KPI tile"
173
205
  tfa-surface todo open 3f2a91... --popout
174
206
  tfa-surface panel open browser --url https://example.com
175
207
  agent-browser connect "$(tfa-surface describe --json | jq -r '.todos[0].browser.cdp')"
@@ -209,6 +241,19 @@ async function call(ctx, path, body, method = "POST") {
209
241
  die(`HTTP ${res.status}: ${JSON.stringify(data)}`);
210
242
  return data;
211
243
  }
244
+ async function rest(ctx, method, path, body) {
245
+ const { res, token } = await fetchWithRenew(`${ctx.apiUrl}/dst/v1${path}`, { method, body }, {
246
+ apiUrl: ctx.apiUrl,
247
+ token: ctx.token,
248
+ renewOn: [401, 403]
249
+ });
250
+ ctx.token = token;
251
+ if (res.status >= 400) {
252
+ const data = await res.json().catch(() => ({}));
253
+ die(`HTTP ${res.status}: ${data?.error ?? JSON.stringify(data)}`);
254
+ }
255
+ return res;
256
+ }
212
257
  async function uiCall(ctx, method, params = {}) {
213
258
  if (!ctx.frontendId) {
214
259
  die("no target frontend — set TODOFORAI_FRONTEND_ID or pass --frontend <tabId> (run inside an agent shell, or copy the id from `describe` on another window)", 2);
@@ -246,7 +291,19 @@ function printDescribe(d) {
246
291
  if (t.panel && t.panel !== "browser")
247
292
  console.log(` ${t.panel}`);
248
293
  if (t.browser)
249
- console.log(` browser: ${t.browser.url} cdp: ${t.browser.cdp ?? "(connecting)"}`);
294
+ console.log(` browser: ${t.browser.open ? t.browser.url : "(idle)"} cdp: ${t.browser.cdp}`);
295
+ if (t.shows?.length) {
296
+ console.log(" shows:");
297
+ for (const s of t.shows) {
298
+ const bits = [
299
+ s.card ? `card=${s.card}` : "",
300
+ s.mime || "",
301
+ s.url ? `url ${s.url}` : "",
302
+ s.title || ""
303
+ ].filter(Boolean);
304
+ console.log(` ${s.ref}${bits.length ? ` ${bits.join(" ")}` : ""}`);
305
+ }
306
+ }
250
307
  }
251
308
  }
252
309
  async function main() {
@@ -255,6 +312,9 @@ async function main() {
255
312
  frontend: { type: "string" },
256
313
  todo: { type: "string" },
257
314
  url: { type: "string" },
315
+ project: { type: "string" },
316
+ title: { type: "string" },
317
+ description: { type: "string" },
258
318
  popout: { type: "boolean", default: false },
259
319
  "api-url": { type: "string" },
260
320
  "api-token": { type: "string" },
@@ -283,6 +343,7 @@ async function main() {
283
343
  frontendId: values.frontend || process.env.TODOFORAI_FRONTEND_ID || undefined
284
344
  };
285
345
  const todoArg = () => values.todo || process.env.TODOFORAI_TODO_ID || die("no todo — pass --todo <todoId> or set TODOFORAI_TODO_ID");
346
+ const projectArg = () => values.project || process.env.TODOFORAI_PROJECT_ID || die("no project — pass --project <projectId> or set TODOFORAI_PROJECT_ID");
286
347
  const [cmd, ...args] = positionals;
287
348
  switch (cmd) {
288
349
  case "describe": {
@@ -312,9 +373,10 @@ async function main() {
312
373
  const [verb, surface] = args;
313
374
  if (verb === "open") {
314
375
  if (!surface)
315
- die("usage: tfa-surface panel open changes|preview|browser|terminal [--todo <id>] [--url <url>]");
316
- await uiCall(ctx, "panel.open", { todoId: todoArg(), surface, ...values.url ? { url: values.url } : {} });
317
- console.log(ctx.json ? '{"ok":true}' : "ok");
376
+ die("usage: tfa-surface panel open changes|agents|preview|browser|terminal [--todo <id>] [--url <url>]");
377
+ const result = await uiCall(ctx, "panel.open", { todoId: todoArg(), surface, ...values.url ? { url: values.url } : {} });
378
+ const pending = result?.browserReady === false;
379
+ console.log(ctx.json ? JSON.stringify(result ?? { ok: true }) : pending ? "ok (browser opening — its CDP url appears in `device_list` shortly)" : "ok");
318
380
  } else if (verb === "close") {
319
381
  await uiCall(ctx, "panel.close", { todoId: todoArg() });
320
382
  console.log(ctx.json ? '{"ok":true}' : "ok");
@@ -323,6 +385,57 @@ async function main() {
323
385
  }
324
386
  break;
325
387
  }
388
+ case "card": {
389
+ const [verb, name] = args;
390
+ const cardPath = (n) => `/resources/cards/${projectArg()}/${encodeURIComponent(n)}`;
391
+ if (verb === "add") {
392
+ if (!name)
393
+ die("usage: tfa-surface card add <name> [file.html] [--title <t>] [--description <d>]");
394
+ const file = args[2];
395
+ const html = file ? await (await import("node:fs/promises")).readFile(file, "utf-8") : process.stdin.isTTY ? die("no file given and stdin is a TTY") : await readStdin();
396
+ if (!html.trim())
397
+ die("empty card body");
398
+ const form = new FormData;
399
+ form.append("file", new Blob([html], { type: "text/html" }), `${name}.html`);
400
+ if (values.title)
401
+ form.append("title", values.title);
402
+ if (values.description)
403
+ form.append("description", values.description);
404
+ const card = await (await rest(ctx, "PUT", cardPath(name), form)).json();
405
+ console.log(ctx.json ? JSON.stringify(card) : `ok — card '${name}' saved (instances update live)`);
406
+ } else if (verb === "list") {
407
+ const { items } = await (await rest(ctx, "GET", `/resources/cards/${projectArg()}`)).json();
408
+ if (ctx.json) {
409
+ console.log(JSON.stringify(items));
410
+ break;
411
+ }
412
+ if (!items.length) {
413
+ console.log("no cards in this project");
414
+ break;
415
+ }
416
+ for (const c of items) {
417
+ console.log(`${c.name}${c.title ? ` # ${c.title}` : ""}`);
418
+ if (c.description)
419
+ console.log(` ${c.description}`);
420
+ }
421
+ } else if (verb === "get") {
422
+ if (!name)
423
+ die("usage: tfa-surface card get <name>");
424
+ if (ctx.json) {
425
+ console.log(JSON.stringify(await (await rest(ctx, "GET", cardPath(name))).json()));
426
+ } else {
427
+ process.stdout.write(await (await rest(ctx, "GET", `${cardPath(name)}?body=1`)).text());
428
+ }
429
+ } else if (verb === "rm") {
430
+ if (!name)
431
+ die("usage: tfa-surface card rm <name>");
432
+ await rest(ctx, "DELETE", cardPath(name));
433
+ console.log(ctx.json ? '{"ok":true}' : "ok");
434
+ } else {
435
+ die("usage: tfa-surface card add|list|get|rm ...");
436
+ }
437
+ break;
438
+ }
326
439
  case "show": {
327
440
  const [surface, target] = args;
328
441
  if (!surface || !target)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@todoforai/tfa-surface",
3
- "version": "0.1.0",
4
- "description": "TODOforAI surface CLI — see and drive what is shown in the user's live UI (describe, todo/panel views, surface render, controls). Replaces tfa-frontend. Reuses bridge credentials.",
3
+ "version": "0.1.2",
4
+ "description": "TODOforAI surface CLI — see and drive what is shown in the user's live UI (describe, todo/panel views, renderer cards, surface render, controls). Replaces tfa-frontend. Reuses bridge credentials.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tfa-surface": "dist/cli.js"
@@ -14,8 +14,16 @@
14
14
  "@todoforai/tfa-subagent": "^0.1.9",
15
15
  "@types/node": "^20.11.0"
16
16
  },
17
- "keywords": ["cli", "frontend", "surfaces", "ui-control", "todoforai"],
17
+ "keywords": [
18
+ "cli",
19
+ "frontend",
20
+ "surfaces",
21
+ "ui-control",
22
+ "todoforai"
23
+ ],
18
24
  "author": "TODO for AI",
19
25
  "license": "MIT",
20
- "files": ["dist"]
26
+ "files": [
27
+ "dist"
28
+ ]
21
29
  }