@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.
- package/dist/cli.js +371 -0
- 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
|
+
}
|