@todoforai/tfa-surface 0.1.0

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 +371 -0
  2. package/package.json +21 -0
package/dist/cli.js ADDED
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // ../tfa-subagent/dist/auth.js
7
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { homedir, platform } from "node:os";
10
+ var DEFAULT_API_URL = "https://api.todofor.ai";
11
+ function normalizeApiUrl(url) {
12
+ if (url.startsWith("localhost"))
13
+ return `http://${url}`;
14
+ if (!url.startsWith("http://") && !url.startsWith("https://"))
15
+ return `https://${url}`;
16
+ return url.replace(/\/+$/, "");
17
+ }
18
+ function credentialsPath() {
19
+ 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
+ }
21
+ function credentialsPaths() {
22
+ return [credentialsPath()];
23
+ }
24
+ function writeSecure(p, data) {
25
+ mkdirSync(dirname(p), { recursive: true });
26
+ writeFileSync(p, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 384 });
27
+ try {
28
+ chmodSync(p, 384);
29
+ } catch {}
30
+ }
31
+ 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: "" };
47
+ }
48
+ 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
+ }
57
+ }
58
+ async function refreshDeviceToken(opts = {}) {
59
+ const envUrl = process.env.TODOFORAI_API_URL || process.env.API_URL;
60
+ const initialApiUrl = normalizeApiUrl(opts.apiUrl || envUrl || DEFAULT_API_URL);
61
+ const stored = readStoredAuth(initialApiUrl);
62
+ const apiUrl = normalizeApiUrl(opts.apiUrl || envUrl || stored.apiUrl || DEFAULT_API_URL);
63
+ if (!stored.deviceId || !stored.deviceSecret)
64
+ return "";
65
+ try {
66
+ const res = await fetch(`${apiUrl}/api/v1/cli/device/token`, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ deviceId: stored.deviceId, secret: stored.deviceSecret })
70
+ });
71
+ if (!res.ok)
72
+ return "";
73
+ const data = await res.json();
74
+ if (!data.token)
75
+ return "";
76
+ persistApiToken(data.token);
77
+ return data.token;
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+ async function fetchWithRenew(url, init, opts) {
83
+ const header = opts.header ?? "x-api-key";
84
+ 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_")) {
88
+ const fresh = await refreshDeviceToken({ apiUrl: opts.apiUrl });
89
+ if (fresh && fresh !== opts.token)
90
+ return { res: await send(fresh), token: fresh };
91
+ }
92
+ return { res, token: opts.token };
93
+ }
94
+ function resolveToken(opts = {}) {
95
+ const envUrl = process.env.TODOFORAI_API_URL || process.env.API_URL;
96
+ const initialApiUrl = normalizeApiUrl(opts.apiUrl || envUrl || DEFAULT_API_URL);
97
+ const stored = readStoredAuth(initialApiUrl);
98
+ const apiUrl = normalizeApiUrl(opts.apiUrl || envUrl || stored.apiUrl || DEFAULT_API_URL);
99
+ const apiToken = opts.apiToken || process.env.TODOFORAI_API_TOKEN || stored.apiToken || "";
100
+ if (!apiToken)
101
+ 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
+ return { apiUrl, apiToken };
103
+ }
104
+ // package.json
105
+ var package_default = {
106
+ 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.",
109
+ type: "module",
110
+ bin: {
111
+ "tfa-surface": "dist/cli.js"
112
+ },
113
+ scripts: {
114
+ build: "bun build src/cli.ts --outdir dist --target node --format esm",
115
+ dev: "bun run src/cli.ts"
116
+ },
117
+ devDependencies: {
118
+ "@todoforai/tfa-subagent": "^0.1.9",
119
+ "@types/node": "^20.11.0"
120
+ },
121
+ keywords: ["cli", "frontend", "surfaces", "ui-control", "todoforai"],
122
+ author: "TODO for AI",
123
+ license: "MIT",
124
+ files: ["dist"]
125
+ };
126
+
127
+ // src/cli.ts
128
+ var VERSION = package_default.version;
129
+ function printHelp() {
130
+ console.log(`tfa-surface — see and drive what is shown in the user's live TODOforAI UI.
131
+
132
+ Target frontend: $TODOFORAI_FRONTEND_ID (a tabId). Override per call:
133
+ TODOFORAI_FRONTEND_ID=<tabId> tfa-surface describe
134
+
135
+ Usage:
136
+ tfa-surface <command> [args...]
137
+
138
+ Discovery:
139
+ describe What's open in the target frontend:
140
+ todos, focus, panel surfaces, sidebar,
141
+ embedded-browser CDP URL. YAML; --json
142
+ for the raw structure.
143
+
144
+ Todos (views):
145
+ todo open <todoId> [--popout] Open a todo (full-page overlay); --popout
146
+ forces a floating preview window.
147
+ todo focus <todoId> Focus an open todo's window.
148
+ todo close <todoId> Close an open todo view.
149
+
150
+ Panels (surfaces inside a todo; --todo defaults to $TODOFORAI_TODO_ID):
151
+ panel open <surface> [--todo <id>] [--url <url>]
152
+ surface: changes|preview|browser|terminal
153
+ panel close [--todo <id>]
154
+
155
+ Content (inherited from tfa-frontend, broadcast to all tabs):
156
+ show <surface> <target> [markdown] Render markdown (stdin when omitted).
157
+ clear <surface> <target> Clear a surface.
158
+ get <control> <target> Read a control's value.
159
+ set <control> <target> <json> Set a control (JSON value).
160
+ catalog Print the capability catalog (JSON).
161
+ whoami Check auth.
162
+
163
+ Options:
164
+ --frontend <tabId> Target frontend (overrides $TODOFORAI_FRONTEND_ID)
165
+ --api-url <url> Backend URL (default: env/credentials)
166
+ --api-token <tok> Token (default: env/credentials)
167
+ --json Machine-readable output
168
+ -h, --help This help
169
+ --version Version
170
+
171
+ Examples:
172
+ tfa-surface describe
173
+ tfa-surface todo open 3f2a91... --popout
174
+ tfa-surface panel open browser --url https://example.com
175
+ agent-browser connect "$(tfa-surface describe --json | jq -r '.todos[0].browser.cdp')"
176
+ git diff --stat | tfa-surface show chat.panel $TODOFORAI_TODO_ID
177
+
178
+ Exit codes: 0 ok · 1 error · 2 frontend not reachable / no frontend id`);
179
+ }
180
+ function die(msg, code = 1) {
181
+ console.error(`Error: ${msg}`);
182
+ process.exit(code);
183
+ }
184
+ async function readStdin() {
185
+ const chunks = [];
186
+ for await (const chunk of process.stdin)
187
+ chunks.push(chunk);
188
+ return Buffer.concat(chunks).toString("utf-8");
189
+ }
190
+ async function call(ctx, path, body, method = "POST") {
191
+ const init = {
192
+ method,
193
+ headers: body !== undefined ? { "content-type": "application/json" } : {},
194
+ body: body !== undefined ? JSON.stringify(body) : undefined
195
+ };
196
+ const { res, token } = await fetchWithRenew(`${ctx.apiUrl}/dst/v1${path}`, init, {
197
+ apiUrl: ctx.apiUrl,
198
+ token: ctx.token,
199
+ renewOn: [401, 403]
200
+ });
201
+ ctx.token = token;
202
+ const data = await res.json().catch(() => ({}));
203
+ if (res.status === 412)
204
+ die(`${data?.message ?? "frontend not connected"}`, 2);
205
+ if (res.status === 408 || data?.message?.includes("did not respond")) {
206
+ die(`frontend not reachable — is a browser tab or the desktop app open? (${data?.message ?? res.status})`, 2);
207
+ }
208
+ if (res.status >= 400)
209
+ die(`HTTP ${res.status}: ${JSON.stringify(data)}`);
210
+ return data;
211
+ }
212
+ async function uiCall(ctx, method, params = {}) {
213
+ if (!ctx.frontendId) {
214
+ 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);
215
+ }
216
+ const data = await call(ctx, "/frontend/call", {
217
+ functionName: "ui_call",
218
+ args: { method, params },
219
+ targetFrontendId: ctx.frontendId
220
+ });
221
+ if (data.error)
222
+ die(data.error);
223
+ return data.result;
224
+ }
225
+ function printResult(ctx, data) {
226
+ if (data.error)
227
+ die(data.error);
228
+ if (ctx.json) {
229
+ console.log(JSON.stringify(data.result ?? { ok: true }));
230
+ return;
231
+ }
232
+ if (data.result && "value" in data.result) {
233
+ const v = data.result.value;
234
+ console.log(typeof v === "string" ? v : JSON.stringify(v));
235
+ } else {
236
+ console.log("ok");
237
+ }
238
+ }
239
+ function printDescribe(d) {
240
+ console.log(`frontend: ${d.frontendId} (${d.platform}, ${d.kind})`);
241
+ console.log(`sidebar: ${d.sidebar}`);
242
+ console.log(d.todos.length ? "todos:" : "todos: []");
243
+ for (const t of d.todos) {
244
+ const flags = [t.mode === "window" ? "window" : "", t.focused ? "focused" : ""].filter(Boolean).join(", ");
245
+ console.log(` ${t.todoId}:${flags ? ` ${flags}` : ""}${t.title ? ` # ${t.title}` : ""}`);
246
+ if (t.panel && t.panel !== "browser")
247
+ console.log(` ${t.panel}`);
248
+ if (t.browser)
249
+ console.log(` browser: ${t.browser.url} cdp: ${t.browser.cdp ?? "(connecting)"}`);
250
+ }
251
+ }
252
+ async function main() {
253
+ const { values, positionals } = parseArgs({
254
+ options: {
255
+ frontend: { type: "string" },
256
+ todo: { type: "string" },
257
+ url: { type: "string" },
258
+ popout: { type: "boolean", default: false },
259
+ "api-url": { type: "string" },
260
+ "api-token": { type: "string" },
261
+ json: { type: "boolean", default: false },
262
+ help: { type: "boolean", short: "h", default: false },
263
+ version: { type: "boolean", default: false }
264
+ },
265
+ allowPositionals: true
266
+ });
267
+ if (values.version) {
268
+ console.log(VERSION);
269
+ return;
270
+ }
271
+ if (values.help || positionals.length === 0) {
272
+ printHelp();
273
+ return;
274
+ }
275
+ const { apiUrl, apiToken } = resolveToken({
276
+ apiUrl: values["api-url"],
277
+ apiToken: values["api-token"]
278
+ });
279
+ const ctx = {
280
+ apiUrl,
281
+ token: apiToken,
282
+ json: !!values.json,
283
+ frontendId: values.frontend || process.env.TODOFORAI_FRONTEND_ID || undefined
284
+ };
285
+ const todoArg = () => values.todo || process.env.TODOFORAI_TODO_ID || die("no todo — pass --todo <todoId> or set TODOFORAI_TODO_ID");
286
+ const [cmd, ...args] = positionals;
287
+ switch (cmd) {
288
+ case "describe": {
289
+ const result = await uiCall(ctx, "ui.describe");
290
+ if (ctx.json)
291
+ console.log(JSON.stringify(result));
292
+ else
293
+ printDescribe(result);
294
+ break;
295
+ }
296
+ case "todo": {
297
+ const [verb, todoId] = args;
298
+ if (!verb || !todoId)
299
+ die("usage: tfa-surface todo open|focus|close <todoId>");
300
+ if (verb === "open") {
301
+ const result = await uiCall(ctx, "todo.open", { todoId, ...values.popout ? { mode: "window" } : {} });
302
+ console.log(ctx.json ? JSON.stringify(result) : `${result.created ? "opened" : "already open"} (${result.mode})`);
303
+ } else if (verb === "focus" || verb === "close") {
304
+ await uiCall(ctx, `todo.${verb}`, { todoId });
305
+ console.log(ctx.json ? '{"ok":true}' : "ok");
306
+ } else {
307
+ die(`unknown todo verb: ${verb}`);
308
+ }
309
+ break;
310
+ }
311
+ case "panel": {
312
+ const [verb, surface] = args;
313
+ if (verb === "open") {
314
+ 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");
318
+ } else if (verb === "close") {
319
+ await uiCall(ctx, "panel.close", { todoId: todoArg() });
320
+ console.log(ctx.json ? '{"ok":true}' : "ok");
321
+ } else {
322
+ die("usage: tfa-surface panel open|close ...");
323
+ }
324
+ break;
325
+ }
326
+ case "show": {
327
+ const [surface, target] = args;
328
+ if (!surface || !target)
329
+ die("usage: tfa-surface show <surface> <target> [markdown]");
330
+ const content = args[2] ?? (process.stdin.isTTY ? die("no markdown given and stdin is a TTY") : await readStdin());
331
+ if (!content.trim())
332
+ die("empty content — use `clear` to clear a surface");
333
+ printResult(ctx, await call(ctx, "/frontend/call", { functionName: "surface_render", args: { surface, target, content } }));
334
+ break;
335
+ }
336
+ case "clear": {
337
+ const [surface, target] = args;
338
+ if (!surface || !target)
339
+ die("usage: tfa-surface clear <surface> <target>");
340
+ printResult(ctx, await call(ctx, "/frontend/call", { functionName: "surface_clear", args: { surface, target } }));
341
+ break;
342
+ }
343
+ case "get": {
344
+ const [control, target] = args;
345
+ if (!control || !target)
346
+ die("usage: tfa-surface get <control> <target>");
347
+ printResult(ctx, await call(ctx, "/frontend/call", { functionName: "control_get", args: { control, target } }));
348
+ break;
349
+ }
350
+ case "set": {
351
+ const [control, target, value] = args;
352
+ if (!control || !target || value === undefined)
353
+ die("usage: tfa-surface set <control> <target> <json-value>");
354
+ printResult(ctx, await call(ctx, "/frontend/call", { functionName: "control_set", args: { control, target, value } }));
355
+ break;
356
+ }
357
+ case "catalog": {
358
+ const data = await call(ctx, "/frontend/catalog", undefined, "GET");
359
+ console.log(JSON.stringify(data, null, 2));
360
+ break;
361
+ }
362
+ case "whoami": {
363
+ await call(ctx, "/frontend/catalog", undefined, "GET");
364
+ console.log(`tfa-surface: authenticated (${ctx.apiUrl})`);
365
+ break;
366
+ }
367
+ default:
368
+ die(`unknown command: ${cmd} (see tfa-surface --help)`);
369
+ }
370
+ }
371
+ main().catch((e) => die(e instanceof Error ? e.message : String(e)));
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
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.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tfa-surface": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "bun build src/cli.ts --outdir dist --target node --format esm",
11
+ "dev": "bun run src/cli.ts"
12
+ },
13
+ "devDependencies": {
14
+ "@todoforai/tfa-subagent": "^0.1.9",
15
+ "@types/node": "^20.11.0"
16
+ },
17
+ "keywords": ["cli", "frontend", "surfaces", "ui-control", "todoforai"],
18
+ "author": "TODO for AI",
19
+ "license": "MIT",
20
+ "files": ["dist"]
21
+ }