@timetotest/cli 0.1.1
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 +263 -0
- package/dist/bin/ttt.js +30 -0
- package/dist/bin/ttt.js.map +1 -0
- package/dist/src/commands/ask.js +864 -0
- package/dist/src/commands/ask.js.map +1 -0
- package/dist/src/commands/login.js +257 -0
- package/dist/src/commands/login.js.map +1 -0
- package/dist/src/commands/report.js +25 -0
- package/dist/src/commands/report.js.map +1 -0
- package/dist/src/commands/restart.js +17 -0
- package/dist/src/commands/restart.js.map +1 -0
- package/dist/src/commands/share.js +18 -0
- package/dist/src/commands/share.js.map +1 -0
- package/dist/src/commands/start-test.js +97 -0
- package/dist/src/commands/start-test.js.map +1 -0
- package/dist/src/commands/status.js +17 -0
- package/dist/src/commands/status.js.map +1 -0
- package/dist/src/commands/stream.js +20 -0
- package/dist/src/commands/stream.js.map +1 -0
- package/dist/src/commands/test.js +129 -0
- package/dist/src/commands/test.js.map +1 -0
- package/dist/src/lib/config.js +120 -0
- package/dist/src/lib/config.js.map +1 -0
- package/dist/src/lib/help.js +94 -0
- package/dist/src/lib/help.js.map +1 -0
- package/dist/src/lib/http.js +16 -0
- package/dist/src/lib/http.js.map +1 -0
- package/dist/src/lib/ngrok.js +35 -0
- package/dist/src/lib/ngrok.js.map +1 -0
- package/dist/src/lib/socket.js +16 -0
- package/dist/src/lib/socket.js.map +1 -0
- package/dist/src/lib/tui.js +509 -0
- package/dist/src/lib/tui.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { createHttpClient } from "../lib/http.js";
|
|
6
|
+
import { connectAndSubscribe } from "../lib/socket.js";
|
|
7
|
+
import { promptInBox, printDivider, formatStatusSegments, accentText, highlightText, printBanner, } from "../lib/tui.js";
|
|
8
|
+
import { getAuthToken, getUserLastProject, setUserLastProject, } from "../lib/config.js";
|
|
9
|
+
import { performInteractiveLogin } from "./login.js";
|
|
10
|
+
function formatResponseText(text) {
|
|
11
|
+
if (!text)
|
|
12
|
+
return text;
|
|
13
|
+
// Convert HTML-like tags to CLI-friendly formatting
|
|
14
|
+
let formatted = text
|
|
15
|
+
// Convert <list> to a simple list
|
|
16
|
+
.replace(/<list>/g, "")
|
|
17
|
+
.replace(/<\/list>/g, "")
|
|
18
|
+
// Convert <item> to bullet points
|
|
19
|
+
.replace(/<item>/g, "• ")
|
|
20
|
+
.replace(/<\/item>/g, "")
|
|
21
|
+
// Convert <strong> to bold
|
|
22
|
+
.replace(/<strong>/g, chalk.bold(""))
|
|
23
|
+
.replace(/<\/strong>/g, chalk.reset(""))
|
|
24
|
+
// Convert <em> to italic
|
|
25
|
+
.replace(/<em>/g, chalk.italic(""))
|
|
26
|
+
.replace(/<\/em>/g, chalk.reset(""))
|
|
27
|
+
// Convert <code> to monospace
|
|
28
|
+
.replace(/<code>/g, chalk.gray(""))
|
|
29
|
+
.replace(/<\/code>/g, chalk.reset(""))
|
|
30
|
+
// Convert line breaks to proper newlines
|
|
31
|
+
.replace(/\n/g, "\n");
|
|
32
|
+
return formatted;
|
|
33
|
+
}
|
|
34
|
+
export const ask = new Command("ask")
|
|
35
|
+
.description("Start interactive chat with TimetoTest AI agent")
|
|
36
|
+
.option("--project-id <id>", "Project ID to use for the conversation")
|
|
37
|
+
.option("--conversation-id <id>", "Continue existing conversation")
|
|
38
|
+
.action(async (opts) => {
|
|
39
|
+
try {
|
|
40
|
+
// Check authentication
|
|
41
|
+
const token = getAuthToken();
|
|
42
|
+
if (!token) {
|
|
43
|
+
console.log(chalk.yellow("You are not logged in. Opening browser to authenticate…"));
|
|
44
|
+
try {
|
|
45
|
+
await performInteractiveLogin();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.log(chalk.red(e?.message || "Login failed"));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let http = createHttpClient();
|
|
53
|
+
const drawIntro = () => {
|
|
54
|
+
console.clear();
|
|
55
|
+
printBanner();
|
|
56
|
+
console.log(chalk.white("Tips for getting started:"));
|
|
57
|
+
console.log(`${chalk.dim("1.")} ${chalk.white("Ask questions, inspect files, or run commands.")}`);
|
|
58
|
+
console.log(`${chalk.dim("2.")} ${chalk.white("Be specific for the clearest answers.")}`);
|
|
59
|
+
console.log(`${chalk.dim("3.")} ${chalk.white(`Share extra context with ${accentText("@path/to/file")}.`)}`);
|
|
60
|
+
console.log(`${chalk.dim("4.")} ${chalk.white(`${accentText("/help")} shows slash commands and shortcuts.`)}`);
|
|
61
|
+
console.log();
|
|
62
|
+
};
|
|
63
|
+
// Gemini-like intro: banner + tips, then single active input box (no duplicate)
|
|
64
|
+
drawIntro();
|
|
65
|
+
let conversationId = opts.conversationId;
|
|
66
|
+
let projectId;
|
|
67
|
+
if (opts.projectId !== undefined) {
|
|
68
|
+
const parsedProjectId = Number(opts.projectId);
|
|
69
|
+
if (!Number.isInteger(parsedProjectId)) {
|
|
70
|
+
console.log(chalk.red(`❌ Invalid project id '${opts.projectId}'. Please provide a numeric value.`));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
projectId = parsedProjectId;
|
|
74
|
+
}
|
|
75
|
+
let sessionInfoWarningShown = false;
|
|
76
|
+
let lastKnownUser = "Unknown";
|
|
77
|
+
let lastKnownUserKey;
|
|
78
|
+
let lastKnownProject = projectId ? `Project ${projectId}` : "Not set";
|
|
79
|
+
const renderSessionSummary = async (refresh = true, retried = false) => {
|
|
80
|
+
if (refresh) {
|
|
81
|
+
try {
|
|
82
|
+
const userResp = await http.get("/api/v1/auth/me");
|
|
83
|
+
lastKnownUserKey =
|
|
84
|
+
userResp.data.email || String(userResp.data.user_id || "");
|
|
85
|
+
lastKnownUser =
|
|
86
|
+
userResp.data.email || userResp.data.display_name || "Unknown";
|
|
87
|
+
if (projectId) {
|
|
88
|
+
const projectResp = await http.get(`/api/v1/projects/${projectId}`);
|
|
89
|
+
lastKnownProject =
|
|
90
|
+
projectResp.data.name || `Project ${projectId}`;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
lastKnownProject = "Not set";
|
|
94
|
+
}
|
|
95
|
+
sessionInfoWarningShown = false;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!retried) {
|
|
99
|
+
// Attempt to re-auth then retry once
|
|
100
|
+
const msg = chalk.gray("Re-authenticating to refresh session information…");
|
|
101
|
+
console.log(msg);
|
|
102
|
+
try {
|
|
103
|
+
await performInteractiveLogin();
|
|
104
|
+
http = createHttpClient();
|
|
105
|
+
await renderSessionSummary(true, true);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch (reauthErr) {
|
|
109
|
+
// fall through to warning message below
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!sessionInfoWarningShown) {
|
|
113
|
+
console.log(chalk.gray("ℹ️ Unable to refresh session info from the API."));
|
|
114
|
+
sessionInfoWarningShown = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
console.log(`${chalk.gray("Signed in as")} ${chalk.white(lastKnownUser)}`);
|
|
119
|
+
console.log(`${chalk.gray("Project")} ${accentText(lastKnownProject)} ${chalk.gray("· use /switch to change")}`);
|
|
120
|
+
console.log();
|
|
121
|
+
};
|
|
122
|
+
let spinner = null;
|
|
123
|
+
let messageCount = 1;
|
|
124
|
+
let socket;
|
|
125
|
+
// Function to switch projects
|
|
126
|
+
const switchProject = async (searchTerm) => {
|
|
127
|
+
try {
|
|
128
|
+
const fetchSpinner = ora("Fetching your projects...").start();
|
|
129
|
+
const projectsResp = await http.get("/api/v1/projects");
|
|
130
|
+
const projectsRaw = projectsResp.data;
|
|
131
|
+
const projects = Array.isArray(projectsRaw?.items)
|
|
132
|
+
? projectsRaw.items
|
|
133
|
+
: Array.isArray(projectsRaw)
|
|
134
|
+
? projectsRaw
|
|
135
|
+
: [];
|
|
136
|
+
fetchSpinner.succeed("Projects loaded!");
|
|
137
|
+
if (!projects || projects.length === 0) {
|
|
138
|
+
console.log(chalk.yellow("❌ No projects available."));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const findProject = (query) => {
|
|
142
|
+
const normalized = query.trim().toLowerCase();
|
|
143
|
+
return projects.find((project) => {
|
|
144
|
+
if (!project)
|
|
145
|
+
return false;
|
|
146
|
+
const name = String(project.name || "").toLowerCase();
|
|
147
|
+
const idMatch = String(project.id || "") === query.trim();
|
|
148
|
+
return idMatch || name.includes(normalized);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
let selectedProject;
|
|
152
|
+
if (searchTerm && searchTerm.trim().length > 0) {
|
|
153
|
+
selectedProject = findProject(searchTerm.trim());
|
|
154
|
+
if (!selectedProject) {
|
|
155
|
+
console.log(chalk.yellow(`No project matched "${searchTerm}". Showing the list instead.`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!selectedProject) {
|
|
159
|
+
console.log();
|
|
160
|
+
printDivider("Available Projects");
|
|
161
|
+
projects.forEach((project, index) => {
|
|
162
|
+
const isCurrent = project.id === projectId;
|
|
163
|
+
const marker = isCurrent ? chalk.green("●") : chalk.gray("○");
|
|
164
|
+
console.log(`${marker} ${chalk.cyan(project.name)}`);
|
|
165
|
+
console.log(` ${chalk.gray(project.description || "No description")}`);
|
|
166
|
+
if (index < projects.length - 1)
|
|
167
|
+
console.log();
|
|
168
|
+
});
|
|
169
|
+
printDivider();
|
|
170
|
+
const { value: selection, aborted } = await promptInBox({
|
|
171
|
+
label: "Switch Project",
|
|
172
|
+
placeholder: "Start typing a project name or ID...",
|
|
173
|
+
hint: "Enter to switch · leave empty to cancel",
|
|
174
|
+
infoLines: [
|
|
175
|
+
chalk.gray("Matching is case-insensitive and accepts partial names."),
|
|
176
|
+
chalk.gray("Press Ctrl+C to cancel the switch."),
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
if (aborted || selection.length === 0) {
|
|
180
|
+
console.log(chalk.gray("Project switch cancelled."));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
selectedProject = findProject(selection);
|
|
184
|
+
if (!selectedProject) {
|
|
185
|
+
console.log(chalk.red(`❌ Project "${selection}" not found.`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!selectedProject) {
|
|
190
|
+
console.log(chalk.red("❌ Unable to determine project."));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (selectedProject.id === projectId) {
|
|
194
|
+
console.log(chalk.yellow(`ℹ️ Already on project "${selectedProject.name}".`));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
projectId = selectedProject.id;
|
|
198
|
+
lastKnownProject = selectedProject.name || `Project ${projectId}`;
|
|
199
|
+
conversationId = undefined;
|
|
200
|
+
messageCount = 1;
|
|
201
|
+
// Do not create a conversation yet; wait for the next user message
|
|
202
|
+
if (socket) {
|
|
203
|
+
try {
|
|
204
|
+
socket.removeAllListeners?.();
|
|
205
|
+
await socket.disconnect();
|
|
206
|
+
}
|
|
207
|
+
catch (disconnectError) {
|
|
208
|
+
console.log(chalk.gray(`ℹ️ Reconnecting socket: ${disconnectError?.message || disconnectError}`));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (lastKnownUserKey && typeof projectId === "number") {
|
|
212
|
+
setUserLastProject(lastKnownUserKey, projectId);
|
|
213
|
+
}
|
|
214
|
+
await renderSessionSummary();
|
|
215
|
+
console.log(chalk.green(`✅ Switched to project: ${selectedProject.name}`));
|
|
216
|
+
console.log();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
console.log(chalk.red(`❌ Failed to switch project: ${error?.message || error}`));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const slashCommands = [];
|
|
223
|
+
const helpCommand = {
|
|
224
|
+
name: "help",
|
|
225
|
+
description: "Show available slash commands",
|
|
226
|
+
aliases: ["?", "commands", "h"],
|
|
227
|
+
run: async () => {
|
|
228
|
+
console.log(chalk.white("Slash commands:"));
|
|
229
|
+
slashCommands.forEach((cmd) => {
|
|
230
|
+
const aliasText = cmd.aliases?.length
|
|
231
|
+
? chalk.gray(` (aliases: ${cmd.aliases
|
|
232
|
+
.map((alias) => `/${alias}`)
|
|
233
|
+
.join(", ")})`)
|
|
234
|
+
: "";
|
|
235
|
+
console.log(` ${accentText(`/${cmd.name}`)} ${chalk.gray(cmd.description)}${aliasText}`);
|
|
236
|
+
});
|
|
237
|
+
console.log(chalk.gray("Prefix commands with '/' (for example, /switch or /summary)."));
|
|
238
|
+
console.log();
|
|
239
|
+
return "continue";
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const summaryCommand = {
|
|
243
|
+
name: "summary",
|
|
244
|
+
description: "Display the latest session information",
|
|
245
|
+
aliases: ["session", "status"],
|
|
246
|
+
run: async () => {
|
|
247
|
+
await renderSessionSummary();
|
|
248
|
+
return "continue";
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
const switchCommand = {
|
|
252
|
+
name: "switch",
|
|
253
|
+
description: "Switch to a different project",
|
|
254
|
+
aliases: ["project", "s"],
|
|
255
|
+
run: async (args) => {
|
|
256
|
+
const query = args.join(" ");
|
|
257
|
+
await switchProject(query);
|
|
258
|
+
return "continue";
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
const clearCommand = {
|
|
262
|
+
name: "clear",
|
|
263
|
+
description: "Clear the screen and redraw the header",
|
|
264
|
+
aliases: ["cls"],
|
|
265
|
+
run: async () => {
|
|
266
|
+
drawIntro();
|
|
267
|
+
await renderSessionSummary(false);
|
|
268
|
+
return "continue";
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const projectsCommand = {
|
|
272
|
+
name: "projects",
|
|
273
|
+
description: "List your projects",
|
|
274
|
+
aliases: ["list-projects", "proj"],
|
|
275
|
+
run: async () => {
|
|
276
|
+
try {
|
|
277
|
+
const resp = await http.get("/api/v1/projects");
|
|
278
|
+
const items = Array.isArray(resp.data?.items)
|
|
279
|
+
? resp.data.items
|
|
280
|
+
: Array.isArray(resp.data)
|
|
281
|
+
? resp.data
|
|
282
|
+
: [];
|
|
283
|
+
if (!items.length) {
|
|
284
|
+
console.log(chalk.yellow("No projects found."));
|
|
285
|
+
return "continue";
|
|
286
|
+
}
|
|
287
|
+
console.log();
|
|
288
|
+
printDivider("Projects");
|
|
289
|
+
items.forEach((p, i) => {
|
|
290
|
+
const isCurrent = p.id === projectId;
|
|
291
|
+
const marker = isCurrent ? chalk.green("●") : chalk.gray("○");
|
|
292
|
+
const name = p.name || `Project ${p.id}`;
|
|
293
|
+
const def = p.is_default ? chalk.cyan(" (default)") : "";
|
|
294
|
+
console.log(`${marker} ${chalk.cyan(name)}${def}`);
|
|
295
|
+
console.log(` ${chalk.gray(p.description || "No description")}`);
|
|
296
|
+
if (i < items.length - 1)
|
|
297
|
+
console.log();
|
|
298
|
+
});
|
|
299
|
+
printDivider();
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
console.log(chalk.red(`❌ Failed to list projects: ${e?.message || e}`));
|
|
303
|
+
}
|
|
304
|
+
return "continue";
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
const testsCommand = {
|
|
308
|
+
name: "tests",
|
|
309
|
+
description: "List recent tests",
|
|
310
|
+
aliases: ["list-tests"],
|
|
311
|
+
run: async () => {
|
|
312
|
+
try {
|
|
313
|
+
const params = projectId ? `?project_id=${projectId}` : "";
|
|
314
|
+
const resp = await http.get(`/api/v1/my-tests${params}`);
|
|
315
|
+
const items = Array.isArray(resp.data?.items)
|
|
316
|
+
? resp.data.items
|
|
317
|
+
: Array.isArray(resp.data)
|
|
318
|
+
? resp.data
|
|
319
|
+
: [];
|
|
320
|
+
if (!items.length) {
|
|
321
|
+
console.log(chalk.yellow("No tests yet."));
|
|
322
|
+
return "continue";
|
|
323
|
+
}
|
|
324
|
+
console.log();
|
|
325
|
+
printDivider("Tests");
|
|
326
|
+
items.forEach((t, i) => {
|
|
327
|
+
const id = t.id;
|
|
328
|
+
const status = t.status || "";
|
|
329
|
+
const url = t.url || "";
|
|
330
|
+
const type = t.test_type || "";
|
|
331
|
+
console.log(`${chalk.gray("•")} ${chalk.white(`#${id}`)} ${chalk.gray(`(${type} · ${status})`)} ${chalk.cyan(url)}`);
|
|
332
|
+
if (i < items.length - 1)
|
|
333
|
+
console.log();
|
|
334
|
+
});
|
|
335
|
+
printDivider();
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
console.log(chalk.red(`❌ Failed to list tests: ${e?.message || e}`));
|
|
339
|
+
}
|
|
340
|
+
return "continue";
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
const statusCommand = {
|
|
344
|
+
name: "status",
|
|
345
|
+
description: "Get test status: /status <testId>",
|
|
346
|
+
aliases: ["test-status"],
|
|
347
|
+
run: async (args) => {
|
|
348
|
+
const id = Number(args[0]);
|
|
349
|
+
if (!id || Number.isNaN(id)) {
|
|
350
|
+
console.log(chalk.yellow("Usage: /status <testId>"));
|
|
351
|
+
return "continue";
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const resp = await http.get(`/api/v1/tests/${id}/status`);
|
|
355
|
+
const s = resp.data;
|
|
356
|
+
console.log();
|
|
357
|
+
printDivider(`Test #${id} Status`);
|
|
358
|
+
console.log(formatStatusSegments([
|
|
359
|
+
{ label: "status", value: s.status, color: chalk.cyan },
|
|
360
|
+
{
|
|
361
|
+
label: "progress",
|
|
362
|
+
value: `${s.progress_percentage}%`,
|
|
363
|
+
color: chalk.green,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
label: "stage",
|
|
367
|
+
value: s.current_stage || s.currentStage || "",
|
|
368
|
+
color: chalk.white,
|
|
369
|
+
},
|
|
370
|
+
]));
|
|
371
|
+
printDivider();
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
console.log(chalk.red(`❌ Failed to get status: ${e?.message || e}`));
|
|
375
|
+
}
|
|
376
|
+
return "continue";
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
const reportCommand = {
|
|
380
|
+
name: "report",
|
|
381
|
+
description: "Generate a report: /report <testId> [html|json]",
|
|
382
|
+
aliases: ["make-report"],
|
|
383
|
+
run: async (args) => {
|
|
384
|
+
const id = Number(args[0]);
|
|
385
|
+
const fmt = (args[1] || "html").toLowerCase();
|
|
386
|
+
if (!id || Number.isNaN(id)) {
|
|
387
|
+
console.log(chalk.yellow("Usage: /report <testId> [html|json]"));
|
|
388
|
+
return "continue";
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const resp = await http.get(`/api/v1/reports/`, {
|
|
392
|
+
// axios get with params? route is POST /reports/, but it expects query params via function signature
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (_) {
|
|
396
|
+
// Fallback to POST with query params as defined in backend
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const resp = await http.post(`/api/v1/reports/`, null, {
|
|
400
|
+
params: { test_id: id, report_format: fmt },
|
|
401
|
+
});
|
|
402
|
+
const r = resp.data;
|
|
403
|
+
console.log();
|
|
404
|
+
printDivider(`Report for Test #${id}`);
|
|
405
|
+
console.log(`${chalk.gray("report id")} ${chalk.white(r.id)}`);
|
|
406
|
+
if (r.file_path)
|
|
407
|
+
console.log(`${chalk.gray("file")} ${chalk.cyan(r.file_path)}`);
|
|
408
|
+
printDivider();
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
console.log(chalk.red(`❌ Failed to generate report: ${e?.message || e}`));
|
|
412
|
+
}
|
|
413
|
+
return "continue";
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const startTestCommand = {
|
|
417
|
+
name: "test",
|
|
418
|
+
description: "Start a test: /test <url> [ui|api]",
|
|
419
|
+
aliases: ["start-test", "start"],
|
|
420
|
+
run: async (args) => {
|
|
421
|
+
let url = args.find((a) => a.startsWith("http")) || "";
|
|
422
|
+
let type = (args.find((a) => a === "ui" || a === "api") || "ui").toLowerCase();
|
|
423
|
+
if (!url) {
|
|
424
|
+
const { value, aborted } = await promptInBox({
|
|
425
|
+
label: "Start Test",
|
|
426
|
+
placeholder: "Enter site URL (https://...)",
|
|
427
|
+
hint: "Enter to confirm · Ctrl+C to cancel",
|
|
428
|
+
fullWidth: true,
|
|
429
|
+
});
|
|
430
|
+
if (aborted || !value.trim())
|
|
431
|
+
return "continue";
|
|
432
|
+
url = value.trim();
|
|
433
|
+
}
|
|
434
|
+
if (type !== "ui" && type !== "api")
|
|
435
|
+
type = "ui";
|
|
436
|
+
try {
|
|
437
|
+
const resp = await http.post(`/api/v1/start-test`, {
|
|
438
|
+
url,
|
|
439
|
+
test_type: type,
|
|
440
|
+
project_id: projectId,
|
|
441
|
+
user_prompt: `CLI start test for ${url}`,
|
|
442
|
+
});
|
|
443
|
+
const data = resp.data;
|
|
444
|
+
console.log();
|
|
445
|
+
printDivider("Test Started");
|
|
446
|
+
console.log(formatStatusSegments([
|
|
447
|
+
{ label: "id", value: String(data.test_id), color: chalk.white },
|
|
448
|
+
{ label: "status", value: data.status, color: chalk.cyan },
|
|
449
|
+
{
|
|
450
|
+
label: "project",
|
|
451
|
+
value: lastKnownProject,
|
|
452
|
+
color: highlightText,
|
|
453
|
+
},
|
|
454
|
+
]));
|
|
455
|
+
printDivider();
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
console.log(chalk.red(`❌ Failed to start test: ${e?.message || e}`));
|
|
459
|
+
}
|
|
460
|
+
return "continue";
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
const conversationsCommand = {
|
|
464
|
+
name: "conversations",
|
|
465
|
+
description: "List your recent conversations",
|
|
466
|
+
aliases: ["convos", "sessions"],
|
|
467
|
+
run: async () => {
|
|
468
|
+
try {
|
|
469
|
+
const params = projectId ? `?project_id=${projectId}` : "";
|
|
470
|
+
const resp = await http.get(`/api/v1/ask/sessions${params}`);
|
|
471
|
+
const items = Array.isArray(resp.data?.items)
|
|
472
|
+
? resp.data.items
|
|
473
|
+
: Array.isArray(resp.data)
|
|
474
|
+
? resp.data
|
|
475
|
+
: [];
|
|
476
|
+
if (!items.length) {
|
|
477
|
+
console.log(chalk.yellow("No conversations yet."));
|
|
478
|
+
return "continue";
|
|
479
|
+
}
|
|
480
|
+
console.log();
|
|
481
|
+
printDivider("Conversations");
|
|
482
|
+
items.forEach((c, i) => {
|
|
483
|
+
const id = c.conversation_id || c.execution_id || c.id;
|
|
484
|
+
const created = c.created_at || "";
|
|
485
|
+
const status = c.status || "";
|
|
486
|
+
const title = c.goal || c.title || "";
|
|
487
|
+
console.log(`${chalk.gray("•")} ${chalk.white(title || id)} ${chalk.gray(`(${status})`)}`);
|
|
488
|
+
if (created)
|
|
489
|
+
console.log(` ${chalk.gray(created)}`);
|
|
490
|
+
if (i < items.length - 1)
|
|
491
|
+
console.log();
|
|
492
|
+
});
|
|
493
|
+
printDivider();
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
console.log(chalk.red(`❌ Failed to list conversations: ${e?.message || e}`));
|
|
497
|
+
}
|
|
498
|
+
return "continue";
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
const exitCommand = {
|
|
502
|
+
name: "exit",
|
|
503
|
+
description: "End the conversation",
|
|
504
|
+
aliases: ["quit", "bye"],
|
|
505
|
+
run: async () => "exit",
|
|
506
|
+
};
|
|
507
|
+
slashCommands.push(helpCommand, summaryCommand, switchCommand, clearCommand, testsCommand, statusCommand, reportCommand, startTestCommand, projectsCommand, conversationsCommand, exitCommand);
|
|
508
|
+
const commandLookup = new Map();
|
|
509
|
+
slashCommands.forEach((command) => {
|
|
510
|
+
commandLookup.set(command.name, command);
|
|
511
|
+
command.aliases?.forEach((alias) => {
|
|
512
|
+
commandLookup.set(alias, command);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
const executeSlashCommand = async (rawInput) => {
|
|
516
|
+
const trimmed = rawInput.trim();
|
|
517
|
+
if (!trimmed) {
|
|
518
|
+
console.log(chalk.gray("Type / to see the command list."));
|
|
519
|
+
return "continue";
|
|
520
|
+
}
|
|
521
|
+
const [keyword, ...rawArgs] = trimmed.split(/\s+/);
|
|
522
|
+
const command = commandLookup.get(keyword.toLowerCase());
|
|
523
|
+
if (!command) {
|
|
524
|
+
console.log(chalk.yellow(`Unknown command: /${keyword}`));
|
|
525
|
+
console.log(chalk.gray(`Type / to see available commands.`));
|
|
526
|
+
return "continue";
|
|
527
|
+
}
|
|
528
|
+
return await command.run(rawArgs);
|
|
529
|
+
};
|
|
530
|
+
// Load session summary without creating conversation yet
|
|
531
|
+
const summarySpinner = ora("Loading session info...").start();
|
|
532
|
+
// Auto-select last used project for this user, else default
|
|
533
|
+
try {
|
|
534
|
+
const me = await http.get("/api/v1/auth/me");
|
|
535
|
+
lastKnownUserKey = me.data?.email || String(me.data?.user_id || "");
|
|
536
|
+
if (!projectId && lastKnownUserKey) {
|
|
537
|
+
const remembered = getUserLastProject(lastKnownUserKey);
|
|
538
|
+
if (remembered) {
|
|
539
|
+
projectId = remembered;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Fetch user's projects and pick the default if present; otherwise the most recent
|
|
543
|
+
const projectsResp = await http.get("/api/v1/projects");
|
|
544
|
+
const projectsRaw = projectsResp.data;
|
|
545
|
+
const projects = Array.isArray(projectsRaw?.items)
|
|
546
|
+
? projectsRaw.items
|
|
547
|
+
: Array.isArray(projectsRaw)
|
|
548
|
+
? projectsRaw
|
|
549
|
+
: [];
|
|
550
|
+
if (!projectId && projects && projects.length > 0) {
|
|
551
|
+
const defaultProj = projects.find((p) => p?.is_default === true);
|
|
552
|
+
const selected = defaultProj || projects[0];
|
|
553
|
+
if (selected?.id) {
|
|
554
|
+
projectId = selected.id;
|
|
555
|
+
lastKnownProject = selected.name || `Project ${projectId}`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// If we loaded a remembered project, populate the name proactively
|
|
559
|
+
if (projectId &&
|
|
560
|
+
(!lastKnownProject || lastKnownProject === "Not set")) {
|
|
561
|
+
try {
|
|
562
|
+
const p = await http.get(`/api/v1/projects/${projectId}`);
|
|
563
|
+
lastKnownProject = p.data?.name || `Project ${projectId}`;
|
|
564
|
+
}
|
|
565
|
+
catch { }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (_) {
|
|
569
|
+
// non-fatal; continue without preselecting
|
|
570
|
+
}
|
|
571
|
+
await renderSessionSummary();
|
|
572
|
+
summarySpinner.succeed("Ready to chat.");
|
|
573
|
+
const attachSocketHandlers = (socketInstance) => {
|
|
574
|
+
socketInstance.on("ASK_THINKING_STARTED", () => {
|
|
575
|
+
if (spinner) {
|
|
576
|
+
spinner.text = chalk.cyan("🤔 Thinking...");
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
socketInstance.on("ASK_THINKING_STOPPED", () => {
|
|
580
|
+
// Thinking stopped, ready for next input
|
|
581
|
+
});
|
|
582
|
+
socketInstance.on("ASK_TOOL_START", (data) => {
|
|
583
|
+
const message = data.data?.message || `Running ${data.data?.tool || "tool"}...`;
|
|
584
|
+
if (spinner) {
|
|
585
|
+
spinner.text = chalk.blue(`⚙️ ${message}`);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
socketInstance.on("ASK_TOOL_RESULT", (data) => {
|
|
589
|
+
const message = data.data?.message || `Completed ${data.data?.tool || "tool"}`;
|
|
590
|
+
console.log(chalk.green(`✅ ${message}`));
|
|
591
|
+
});
|
|
592
|
+
// Note: ASK_AGENT_ANSWER is handled by ASK_SESSION_COMPLETED to avoid duplicates
|
|
593
|
+
socketInstance.on("ASK_SESSION_COMPLETED", (data) => {
|
|
594
|
+
if (spinner) {
|
|
595
|
+
spinner.stop();
|
|
596
|
+
spinner = null;
|
|
597
|
+
}
|
|
598
|
+
console.log();
|
|
599
|
+
printDivider("Assistant");
|
|
600
|
+
const summary = data.data?.summary || "No response";
|
|
601
|
+
console.log(formatResponseText(summary));
|
|
602
|
+
printDivider();
|
|
603
|
+
console.log();
|
|
604
|
+
});
|
|
605
|
+
socketInstance.on("ASK_SESSION_ERROR", (data) => {
|
|
606
|
+
if (spinner) {
|
|
607
|
+
spinner.fail("An error occurred");
|
|
608
|
+
spinner = null;
|
|
609
|
+
}
|
|
610
|
+
printDivider("Assistant Error");
|
|
611
|
+
console.log(chalk.red(`❌ ${data.data?.error || "Unknown error"}`));
|
|
612
|
+
printDivider();
|
|
613
|
+
console.log();
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
const establishSocket = (conversation) => {
|
|
617
|
+
const newSocket = connectAndSubscribe(conversation);
|
|
618
|
+
attachSocketHandlers(newSocket);
|
|
619
|
+
return newSocket;
|
|
620
|
+
};
|
|
621
|
+
if (conversationId) {
|
|
622
|
+
socket = establishSocket(conversationId);
|
|
623
|
+
}
|
|
624
|
+
// Main conversation loop
|
|
625
|
+
const workspacePath = process.cwd();
|
|
626
|
+
const homeDir = os.homedir();
|
|
627
|
+
const displayPath = workspacePath.startsWith(homeDir)
|
|
628
|
+
? `~${workspacePath.slice(homeDir.length)}`
|
|
629
|
+
: workspacePath;
|
|
630
|
+
while (true) {
|
|
631
|
+
// Show input box for user message
|
|
632
|
+
const firstMessage = messageCount === 1;
|
|
633
|
+
const statusLine = formatStatusSegments([
|
|
634
|
+
{ label: "path", value: displayPath, color: accentText },
|
|
635
|
+
{ label: "project", value: lastKnownProject, color: highlightText },
|
|
636
|
+
]);
|
|
637
|
+
// Removed usage line per request
|
|
638
|
+
// Removed extra hint to keep UI clean
|
|
639
|
+
const getSlashSuggestions = (input) => {
|
|
640
|
+
const q = (input || "").replace(/^\//, "").toLowerCase();
|
|
641
|
+
const priority = {
|
|
642
|
+
test: 0,
|
|
643
|
+
switch: 1,
|
|
644
|
+
status: 2,
|
|
645
|
+
};
|
|
646
|
+
const items = slashCommands
|
|
647
|
+
.map((c) => ({
|
|
648
|
+
label: `/${c.name}`,
|
|
649
|
+
value: c.name,
|
|
650
|
+
description: c.description,
|
|
651
|
+
p: priority[c.name] ?? 100,
|
|
652
|
+
}))
|
|
653
|
+
.sort((a, b) => a.p - b.p || a.value.localeCompare(b.value));
|
|
654
|
+
if (!q)
|
|
655
|
+
return items;
|
|
656
|
+
return items
|
|
657
|
+
.filter((i) => i.value.toLowerCase().startsWith(q) ||
|
|
658
|
+
i.label.toLowerCase().includes(q))
|
|
659
|
+
.sort((a, b) => a.p - b.p || a.value.localeCompare(b.value));
|
|
660
|
+
};
|
|
661
|
+
const { value, aborted } = await promptInBox({
|
|
662
|
+
placeholder: "Type your message or @path/to/file",
|
|
663
|
+
footerLines: [statusLine],
|
|
664
|
+
fullWidth: true,
|
|
665
|
+
getSuggestions: getSlashSuggestions,
|
|
666
|
+
});
|
|
667
|
+
if (aborted) {
|
|
668
|
+
console.log(chalk.gray("Session cancelled."));
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
const userInput = value.trim();
|
|
672
|
+
const normalizedInput = userInput.toLowerCase();
|
|
673
|
+
if (!userInput) {
|
|
674
|
+
console.log(chalk.yellow("Please enter a message or type /exit to leave."));
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (normalizedInput === "exit" || normalizedInput === "quit") {
|
|
678
|
+
console.log(chalk.gray("Goodbye! 👋"));
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
const legacyCommand = {
|
|
682
|
+
help: "help",
|
|
683
|
+
commands: "help",
|
|
684
|
+
"?": "help",
|
|
685
|
+
summary: "summary",
|
|
686
|
+
status: "summary",
|
|
687
|
+
switch: "switch",
|
|
688
|
+
clear: "clear",
|
|
689
|
+
cls: "clear",
|
|
690
|
+
}[normalizedInput];
|
|
691
|
+
if (legacyCommand) {
|
|
692
|
+
console.log(chalk.gray("Tip: commands work best with '/'. Running it for you now..."));
|
|
693
|
+
const outcome = await executeSlashCommand(legacyCommand);
|
|
694
|
+
if (outcome === "exit") {
|
|
695
|
+
console.log(chalk.gray("Goodbye! 👋"));
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (userInput.startsWith("/")) {
|
|
701
|
+
const outcome = await executeSlashCommand(userInput.slice(1));
|
|
702
|
+
if (outcome === "exit") {
|
|
703
|
+
console.log(chalk.gray("Goodbye! 👋"));
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
// Create conversation and establish socket on first message
|
|
709
|
+
if (!conversationId) {
|
|
710
|
+
spinner = ora("Creating conversation...").start();
|
|
711
|
+
try {
|
|
712
|
+
const sessionResp = await http.post("/api/v1/ask/sessions", {
|
|
713
|
+
question: userInput,
|
|
714
|
+
project_id: projectId,
|
|
715
|
+
surface: "cli",
|
|
716
|
+
});
|
|
717
|
+
conversationId = sessionResp.data.conversation_id;
|
|
718
|
+
projectId = sessionResp.data.project_id;
|
|
719
|
+
if (lastKnownUserKey && typeof projectId === "number") {
|
|
720
|
+
setUserLastProject(lastKnownUserKey, projectId);
|
|
721
|
+
}
|
|
722
|
+
// Establish socket connection
|
|
723
|
+
socket = establishSocket(conversationId);
|
|
724
|
+
spinner.text = "Waiting for assistant...";
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
const status = e?.response?.status;
|
|
728
|
+
if (status === 401) {
|
|
729
|
+
spinner.text = "Session expired. Re-authenticating...";
|
|
730
|
+
await performInteractiveLogin();
|
|
731
|
+
http = createHttpClient();
|
|
732
|
+
spinner.text = "Creating conversation...";
|
|
733
|
+
const sessionResp = await http.post("/api/v1/ask/sessions", {
|
|
734
|
+
question: userInput,
|
|
735
|
+
project_id: projectId,
|
|
736
|
+
surface: "cli",
|
|
737
|
+
});
|
|
738
|
+
conversationId = sessionResp.data.conversation_id;
|
|
739
|
+
projectId = sessionResp.data.project_id;
|
|
740
|
+
if (lastKnownUserKey && typeof projectId === "number") {
|
|
741
|
+
setUserLastProject(lastKnownUserKey, projectId);
|
|
742
|
+
}
|
|
743
|
+
// Establish socket connection
|
|
744
|
+
socket = establishSocket(conversationId);
|
|
745
|
+
spinner.text = "Waiting for assistant...";
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
spinner.fail("Failed to create conversation");
|
|
749
|
+
throw e;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
// Send question to existing conversation
|
|
755
|
+
spinner = ora("Sending...").start();
|
|
756
|
+
try {
|
|
757
|
+
await http.post(`/api/v1/ask/sessions/${conversationId}/message`, {
|
|
758
|
+
message: userInput,
|
|
759
|
+
project_id: projectId,
|
|
760
|
+
surface: "cli",
|
|
761
|
+
});
|
|
762
|
+
spinner.text = "Waiting for assistant...";
|
|
763
|
+
}
|
|
764
|
+
catch (e) {
|
|
765
|
+
const status = e?.response?.status;
|
|
766
|
+
if (status === 401) {
|
|
767
|
+
spinner.text = "Session expired. Re-authenticating...";
|
|
768
|
+
await performInteractiveLogin();
|
|
769
|
+
http = createHttpClient();
|
|
770
|
+
spinner.text = "Sending...";
|
|
771
|
+
await http.post(`/api/v1/ask/sessions/${conversationId}/message`, {
|
|
772
|
+
message: userInput,
|
|
773
|
+
project_id: projectId,
|
|
774
|
+
surface: "cli",
|
|
775
|
+
});
|
|
776
|
+
spinner.text = "Waiting for assistant...";
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
throw e;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Create a promise that resolves when agent responds
|
|
784
|
+
const waitForResponse = new Promise((resolve) => {
|
|
785
|
+
const responseHandler = (data) => {
|
|
786
|
+
// Remove the listener once we get a response
|
|
787
|
+
socket.off("ASK_SESSION_COMPLETED", responseHandler);
|
|
788
|
+
socket.off("ASK_SESSION_ERROR", errorHandler);
|
|
789
|
+
resolve();
|
|
790
|
+
};
|
|
791
|
+
const errorHandler = (data) => {
|
|
792
|
+
// Remove the listener once we get an error
|
|
793
|
+
socket.off("ASK_SESSION_COMPLETED", responseHandler);
|
|
794
|
+
socket.off("ASK_SESSION_ERROR", errorHandler);
|
|
795
|
+
resolve();
|
|
796
|
+
};
|
|
797
|
+
// Listen for agent response
|
|
798
|
+
socket.on("ASK_SESSION_COMPLETED", responseHandler);
|
|
799
|
+
socket.on("ASK_SESSION_ERROR", errorHandler);
|
|
800
|
+
});
|
|
801
|
+
let messageDelivered = false;
|
|
802
|
+
try {
|
|
803
|
+
// Wait for the agent response before continuing
|
|
804
|
+
await waitForResponse;
|
|
805
|
+
messageDelivered = true;
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
if (spinner) {
|
|
809
|
+
spinner.fail();
|
|
810
|
+
spinner = null;
|
|
811
|
+
}
|
|
812
|
+
const status = error?.response?.status;
|
|
813
|
+
if (status === 401) {
|
|
814
|
+
console.log(chalk.yellow("Session expired. Re-authenticating…"));
|
|
815
|
+
try {
|
|
816
|
+
await performInteractiveLogin();
|
|
817
|
+
}
|
|
818
|
+
catch (loginError) {
|
|
819
|
+
console.log(chalk.red(loginError?.message || "Re-authentication failed."));
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
http = createHttpClient();
|
|
823
|
+
spinner = ora("Sending...").start();
|
|
824
|
+
try {
|
|
825
|
+
await http.post(`/api/v1/ask/sessions/${conversationId}/message`, {
|
|
826
|
+
message: userInput,
|
|
827
|
+
project_id: projectId,
|
|
828
|
+
surface: "cli",
|
|
829
|
+
});
|
|
830
|
+
spinner.text = "Waiting for assistant...";
|
|
831
|
+
// Wait for response even after re-auth
|
|
832
|
+
await waitForResponse;
|
|
833
|
+
messageDelivered = true;
|
|
834
|
+
}
|
|
835
|
+
catch (err2) {
|
|
836
|
+
if (spinner) {
|
|
837
|
+
spinner.fail();
|
|
838
|
+
spinner = null;
|
|
839
|
+
}
|
|
840
|
+
console.log(chalk.red(`❌ Error sending message: ${err2?.message || err2}`));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
console.log(chalk.red(`❌ Error sending message: ${error?.message || error}`));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (messageDelivered) {
|
|
848
|
+
messageCount += 1;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// Clean up
|
|
852
|
+
if (spinner) {
|
|
853
|
+
spinner.stop();
|
|
854
|
+
}
|
|
855
|
+
if (socket) {
|
|
856
|
+
await socket.disconnect();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
console.error(chalk.red(`❌ Error: ${error.message}`));
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
//# sourceMappingURL=ask.js.map
|