@metagptx/deepflow-cli 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/src/cli.js ADDED
@@ -0,0 +1,531 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import process from "node:process";
3
+
4
+ import { commandFromParsedArgs, parseArgs } from "./args.js";
5
+ import {
6
+ clearAuthConfig,
7
+ DEFAULT_PROFILE,
8
+ resolveRuntimeConfig,
9
+ saveAuthConfig,
10
+ } from "./config.js";
11
+ import {
12
+ buildPlanStatusUrl,
13
+ buildSessionMessagesUrl,
14
+ buildSessionStopUrl,
15
+ DeepFlowClient,
16
+ DEFAULT_FAST_TIMEOUT_MS,
17
+ DEFAULT_TIMEOUT_MS,
18
+ parseTimeoutMs,
19
+ resolveReachableBaseUrl,
20
+ } from "./client.js";
21
+ import { CliError, redactToken } from "./errors.js";
22
+ import { writeError, writeResult } from "./output.js";
23
+ import { VERSION } from "./version.js";
24
+
25
+ function buildIo() {
26
+ return {
27
+ stdout: process.stdout,
28
+ stderr: process.stderr,
29
+ stdin: process.stdin,
30
+ };
31
+ }
32
+
33
+ function helpText() {
34
+ return `DeepFlow CLI
35
+
36
+ Usage:
37
+ deepflow -l -t <token> [--base-url <url>]
38
+ deepflow -login -t <token> [--base-url <url>]
39
+ deepflow auth login -t <token> [--base-url <url>]
40
+ deepflow auth logout
41
+ deepflow auth status [--json]
42
+ deepflow apps list [--json]
43
+ deepflow plan run --repo <repo_id_or_app_code> [--name <name>] [--context <text>] [--prompt <text>] [--json]
44
+ deepflow plan status --iteration <local_iteration_id> --session <session_id> [--json]
45
+ deepflow session messages --backend-iteration <backend_iteration_id> --session <session_id> [--json]
46
+ deepflow session stop --backend-iteration <backend_iteration_id> --session <session_id> [--json]
47
+
48
+ Global options:
49
+ --base-url <url> DeepFlow Web URL. Default: http://localhost:3100
50
+ -t, --token <token> API token. Overrides config and DEEPFLOW_TOKEN.
51
+ --profile <name> Config profile. Default: default
52
+ --json Machine-readable output.
53
+ -h, --help Show help.
54
+
55
+ Environment:
56
+ DEEPFLOW_BASE_URL
57
+ DEEPFLOW_TOKEN
58
+ DEEPFLOW_PROFILE
59
+ DEEPFLOW_CONFIG
60
+ DEEPFLOW_CONFIG_DIR
61
+ `;
62
+ }
63
+
64
+ async function readStdin(io) {
65
+ const chunks = [];
66
+
67
+ for await (const chunk of io.stdin) {
68
+ chunks.push(Buffer.from(chunk));
69
+ }
70
+
71
+ return Buffer.concat(chunks).toString("utf8").trim();
72
+ }
73
+
74
+ async function readTextOption(flags, {
75
+ textName,
76
+ fileName,
77
+ fallback,
78
+ }) {
79
+ if (flags[fileName]) {
80
+ return readFile(flags[fileName], "utf8");
81
+ }
82
+
83
+ return flags[textName] ?? fallback;
84
+ }
85
+
86
+ function requireToken(token) {
87
+ if (!token) {
88
+ throw new CliError("Token is required. Use -t <token> or set DEEPFLOW_TOKEN.", {
89
+ code: "missing_token",
90
+ exitCode: 3,
91
+ });
92
+ }
93
+
94
+ if (!token.startsWith("dfpat_")) {
95
+ throw new CliError("Token must be a DeepFlow personal API token starting with dfpat_.", {
96
+ code: "invalid_token_format",
97
+ exitCode: 2,
98
+ });
99
+ }
100
+ }
101
+
102
+ function requireOption(flags, name, message) {
103
+ const value = flags[name];
104
+
105
+ if (!value) {
106
+ throw new CliError(message, {
107
+ code: "missing_argument",
108
+ exitCode: 2,
109
+ });
110
+ }
111
+
112
+ return value;
113
+ }
114
+
115
+ function makeClient(runtimeConfig, flags) {
116
+ return new DeepFlowClient({
117
+ baseUrl: runtimeConfig.baseUrl,
118
+ token: runtimeConfig.token,
119
+ timeoutMs: parseTimeoutMs(flags.timeout, DEFAULT_TIMEOUT_MS),
120
+ fastTimeoutMs: parseTimeoutMs(flags.fastTimeout, DEFAULT_FAST_TIMEOUT_MS),
121
+ });
122
+ }
123
+
124
+ function resolveRepoIdFromIteration(iteration, repoRef) {
125
+ const snapshots = Array.isArray(iteration?.repoSnapshots) ? iteration.repoSnapshots : [];
126
+
127
+ if (snapshots.length === 1 && snapshots[0]?.repoId) {
128
+ return snapshots[0].repoId;
129
+ }
130
+
131
+ const matched = snapshots.find((snapshot) =>
132
+ [
133
+ snapshot.repoId,
134
+ snapshot.appCode,
135
+ snapshot.repoName,
136
+ snapshot.repositoryUrl,
137
+ ].some((value) => value && value === repoRef),
138
+ );
139
+
140
+ return matched?.repoId ?? repoRef;
141
+ }
142
+
143
+ function buildRepoBranches(repoRef, flags) {
144
+ if (!flags.baseBranch && !flags.targetBranch) {
145
+ return undefined;
146
+ }
147
+
148
+ return {
149
+ [repoRef]: {
150
+ baseBranch: flags.baseBranch ?? null,
151
+ targetBranch: flags.targetBranch ?? null,
152
+ },
153
+ };
154
+ }
155
+
156
+ function buildUiUrl(baseUrl, iteration, sessionId) {
157
+ const routeId = iteration.backendIterationId || iteration.id;
158
+ const path = `/iterations/${encodeURIComponent(routeId)}/plan?sessionId=${encodeURIComponent(sessionId)}`;
159
+
160
+ return new URL(path, baseUrl).toString();
161
+ }
162
+
163
+ function buildSessionLinks(baseUrl, {
164
+ localIterationId,
165
+ backendIterationId,
166
+ sessionId,
167
+ }) {
168
+ return {
169
+ statusUrl: buildPlanStatusUrl(baseUrl, {
170
+ localIterationId,
171
+ sessionId,
172
+ }),
173
+ messagesUrl: buildSessionMessagesUrl(baseUrl, {
174
+ backendIterationId,
175
+ sessionId,
176
+ }),
177
+ stopUrl: buildSessionStopUrl(baseUrl, {
178
+ backendIterationId,
179
+ sessionId,
180
+ }),
181
+ };
182
+ }
183
+
184
+ async function runAuthLogin(flags, env, io) {
185
+ let token = flags.token || env.DEEPFLOW_TOKEN || null;
186
+
187
+ if (flags.tokenStdin) {
188
+ token = await readStdin(io);
189
+ }
190
+
191
+ requireToken(token);
192
+
193
+ const runtimeConfig = await resolveRuntimeConfig({ ...flags, token }, env);
194
+ const client = makeClient({ ...runtimeConfig, token }, flags);
195
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
196
+
197
+ await client.verifyToken("auth.login");
198
+
199
+ const saved = await saveAuthConfig({
200
+ baseUrl: reachableBaseUrl,
201
+ token,
202
+ profile: runtimeConfig.profile,
203
+ }, env);
204
+
205
+ return {
206
+ ok: true,
207
+ method: "auth.login",
208
+ authenticated: true,
209
+ baseUrl: reachableBaseUrl,
210
+ profile: runtimeConfig.profile,
211
+ tokenPrefix: redactToken(token),
212
+ configPath: saved.configPath,
213
+ };
214
+ }
215
+
216
+ async function runAuthLogout(flags, env) {
217
+ const profile = flags.profile || env.DEEPFLOW_PROFILE || DEFAULT_PROFILE;
218
+ const result = await clearAuthConfig(profile, env);
219
+
220
+ return {
221
+ ok: true,
222
+ method: "auth.logout",
223
+ profile,
224
+ configPath: result.configPath,
225
+ };
226
+ }
227
+
228
+ async function runAuthStatus(flags, env) {
229
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
230
+ const result = {
231
+ ok: true,
232
+ method: "auth.status",
233
+ authenticated: Boolean(runtimeConfig.token),
234
+ baseUrl: runtimeConfig.baseUrl,
235
+ profile: runtimeConfig.profile,
236
+ tokenPrefix: runtimeConfig.token ? redactToken(runtimeConfig.token) : null,
237
+ configPath: runtimeConfig.configPath,
238
+ status: null,
239
+ };
240
+
241
+ if (!runtimeConfig.token) {
242
+ return result;
243
+ }
244
+
245
+ const client = makeClient(runtimeConfig, flags);
246
+
247
+ try {
248
+ result.baseUrl = await resolveReachableBaseUrl(client);
249
+ await client.verifyToken("auth.status");
250
+ result.status = "valid";
251
+ } catch (error) {
252
+ result.authenticated = false;
253
+ result.status = error.code || "invalid";
254
+ }
255
+
256
+ return result;
257
+ }
258
+
259
+ async function runAppsList(flags, env) {
260
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
261
+ requireToken(runtimeConfig.token);
262
+ const client = makeClient(runtimeConfig, flags);
263
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
264
+ const items = await client.listApps();
265
+
266
+ return {
267
+ ok: true,
268
+ method: "apps.list",
269
+ baseUrl: reachableBaseUrl,
270
+ items,
271
+ };
272
+ }
273
+
274
+ async function resolveRepoRef(client, flags) {
275
+ if (flags.repo) {
276
+ return flags.repo;
277
+ }
278
+
279
+ const apps = await client.listApps();
280
+ const first = apps.find((app) => app.repositoryUrl);
281
+
282
+ if (!first) {
283
+ throw new CliError("No app is available. Pass --repo <repo_id_or_app_code> after adding an app.", {
284
+ code: "missing_repo",
285
+ exitCode: 2,
286
+ });
287
+ }
288
+
289
+ return first.id || first.appCode;
290
+ }
291
+
292
+ async function runPlanRun(flags, env) {
293
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
294
+ requireToken(runtimeConfig.token);
295
+
296
+ if (flags.runtime && !["claude", "codex"].includes(flags.runtime)) {
297
+ throw new CliError("--runtime must be claude or codex.", {
298
+ code: "bad_runtime",
299
+ exitCode: 2,
300
+ });
301
+ }
302
+
303
+ const client = makeClient(runtimeConfig, flags);
304
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
305
+ const repoRef = await resolveRepoRef(client, flags);
306
+ const context = await readTextOption(flags, {
307
+ textName: "context",
308
+ fileName: "contextFile",
309
+ fallback: "DeepFlow CLI plan run.",
310
+ });
311
+ const prompt = await readTextOption(flags, {
312
+ textName: "prompt",
313
+ fileName: "promptFile",
314
+ fallback:
315
+ "Please create a detailed implementation plan for this iteration. Analyze the repository first, identify risks, and propose validation steps. Do not modify code yet.",
316
+ });
317
+ const iterationName = flags.name || `CLI Plan ${new Date().toISOString()}`;
318
+ const iteration = await client.createIteration({
319
+ name: iterationName,
320
+ repoIds: [repoRef],
321
+ repoBranches: buildRepoBranches(repoRef, flags),
322
+ iterationType: flags.iterationType ?? "feature",
323
+ context,
324
+ });
325
+ const repoId = resolveRepoIdFromIteration(iteration, repoRef);
326
+ const sessionPayload = {
327
+ iterationId: iteration.id,
328
+ repoId,
329
+ forceNew: flags.forceNew !== false,
330
+ };
331
+
332
+ if (flags.runtime) {
333
+ sessionPayload.runtimeType = flags.runtime;
334
+ }
335
+
336
+ const sessionResult = await client.createPlanSession(sessionPayload);
337
+ const session = sessionResult.session;
338
+
339
+ if (!session?.session_id) {
340
+ throw new CliError("Plan session response did not include session.session_id.", {
341
+ code: "bad_response",
342
+ exitCode: 1,
343
+ });
344
+ }
345
+
346
+ const runResult = await client.sendPlanMessage({
347
+ localIterationId: iteration.id,
348
+ sessionId: session.session_id,
349
+ prompt,
350
+ });
351
+ const links = buildSessionLinks(reachableBaseUrl, {
352
+ localIterationId: iteration.id,
353
+ backendIterationId: iteration.backendIterationId,
354
+ sessionId: session.session_id,
355
+ });
356
+
357
+ return {
358
+ ok: true,
359
+ method: "plan.run",
360
+ baseUrl: reachableBaseUrl,
361
+ iterationId: iteration.id,
362
+ backendIterationId: iteration.backendIterationId,
363
+ iterationName: iteration.name,
364
+ repoId,
365
+ planSessionId: session.session_id,
366
+ runId: runResult?.run?.run_id ?? runResult?.run?.id ?? null,
367
+ accepted: runResult?.accepted ?? null,
368
+ uiUrl: buildUiUrl(reachableBaseUrl, iteration, session.session_id),
369
+ ...links,
370
+ };
371
+ }
372
+
373
+ async function runPlanStatus(flags, env) {
374
+ const localIterationId = requireOption(flags, "iteration", "--iteration <local_iteration_id> is required.");
375
+ const sessionId = requireOption(flags, "session", "--session <session_id> is required.");
376
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
377
+ requireToken(runtimeConfig.token);
378
+ const client = makeClient(runtimeConfig, flags);
379
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
380
+ const session = await client.getLocalSession({
381
+ localIterationId,
382
+ sessionId,
383
+ });
384
+
385
+ return {
386
+ ok: true,
387
+ method: "plan.status",
388
+ baseUrl: reachableBaseUrl,
389
+ iterationId: localIterationId,
390
+ sessionId,
391
+ status: session?.status ?? session?.run_status ?? null,
392
+ session,
393
+ };
394
+ }
395
+
396
+ async function runSessionMessages(flags, env) {
397
+ const sessionId = requireOption(flags, "session", "--session <session_id> is required.");
398
+ const backendIterationId = flags.backendIteration || flags.iteration;
399
+
400
+ if (!backendIterationId) {
401
+ throw new CliError("--backend-iteration <backend_iteration_id> is required.", {
402
+ code: "missing_argument",
403
+ exitCode: 2,
404
+ });
405
+ }
406
+
407
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
408
+ requireToken(runtimeConfig.token);
409
+ const client = makeClient(runtimeConfig, flags);
410
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
411
+ const messages = await client.listMessages({
412
+ backendIterationId,
413
+ sessionId,
414
+ });
415
+
416
+ return {
417
+ ok: true,
418
+ method: "session.messages",
419
+ baseUrl: reachableBaseUrl,
420
+ backendIterationId,
421
+ sessionId,
422
+ messages,
423
+ };
424
+ }
425
+
426
+ async function runSessionStop(flags, env) {
427
+ const sessionId = requireOption(flags, "session", "--session <session_id> is required.");
428
+ const backendIterationId = flags.backendIteration || flags.iteration;
429
+
430
+ if (!backendIterationId) {
431
+ throw new CliError("--backend-iteration <backend_iteration_id> is required.", {
432
+ code: "missing_argument",
433
+ exitCode: 2,
434
+ });
435
+ }
436
+
437
+ const runtimeConfig = await resolveRuntimeConfig(flags, env);
438
+ requireToken(runtimeConfig.token);
439
+ const client = makeClient(runtimeConfig, flags);
440
+ const reachableBaseUrl = await resolveReachableBaseUrl(client);
441
+ const response = await client.stopSession({
442
+ backendIterationId,
443
+ sessionId,
444
+ });
445
+
446
+ return {
447
+ ok: true,
448
+ method: "session.stop",
449
+ baseUrl: reachableBaseUrl,
450
+ backendIterationId,
451
+ sessionId,
452
+ response,
453
+ };
454
+ }
455
+
456
+ async function dispatch(command, flags, env, io) {
457
+ const [domain, action] = command;
458
+
459
+ if (!domain) {
460
+ throw new CliError("Missing command. Run deepflow --help.", {
461
+ code: "missing_command",
462
+ exitCode: 2,
463
+ });
464
+ }
465
+
466
+ if (domain === "auth" && action === "login") {
467
+ return runAuthLogin(flags, env, io);
468
+ }
469
+
470
+ if (domain === "auth" && action === "logout") {
471
+ return runAuthLogout(flags, env);
472
+ }
473
+
474
+ if (domain === "auth" && action === "status") {
475
+ return runAuthStatus(flags, env);
476
+ }
477
+
478
+ if (domain === "apps" && action === "list") {
479
+ return runAppsList(flags, env);
480
+ }
481
+
482
+ if (domain === "plan" && action === "run") {
483
+ return runPlanRun(flags, env);
484
+ }
485
+
486
+ if (domain === "plan" && action === "status") {
487
+ return runPlanStatus(flags, env);
488
+ }
489
+
490
+ if (domain === "session" && action === "messages") {
491
+ return runSessionMessages(flags, env);
492
+ }
493
+
494
+ if (domain === "session" && action === "stop") {
495
+ return runSessionStop(flags, env);
496
+ }
497
+
498
+ throw new CliError(`Unknown command: ${command.join(" ")}`, {
499
+ code: "unknown_command",
500
+ exitCode: 2,
501
+ });
502
+ }
503
+
504
+ export async function main(argv = process.argv.slice(2), {
505
+ env = process.env,
506
+ io = buildIo(),
507
+ } = {}) {
508
+ let parsed;
509
+
510
+ try {
511
+ parsed = parseArgs(argv);
512
+
513
+ if (parsed.flags.help) {
514
+ io.stdout.write(helpText());
515
+ return 0;
516
+ }
517
+
518
+ if (parsed.flags.version) {
519
+ io.stdout.write(`${VERSION}\n`);
520
+ return 0;
521
+ }
522
+
523
+ const command = commandFromParsedArgs(parsed);
524
+ const result = await dispatch(command, parsed.flags, env, io);
525
+ writeResult(io, result, { json: parsed.flags.json });
526
+
527
+ return 0;
528
+ } catch (error) {
529
+ return writeError(io, error, { json: parsed?.flags?.json });
530
+ }
531
+ }