@supatest/cli 0.0.2 → 0.0.3
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 +58 -315
- package/dist/agent-runner.js +224 -52
- package/dist/commands/login.js +392 -0
- package/dist/commands/setup.js +234 -0
- package/dist/config.js +29 -0
- package/dist/core/agent.js +270 -0
- package/dist/index.js +118 -31
- package/dist/modes/headless.js +117 -0
- package/dist/modes/interactive.js +430 -0
- package/dist/presenters/composite.js +32 -0
- package/dist/presenters/console.js +163 -0
- package/dist/presenters/react.js +220 -0
- package/dist/presenters/types.js +1 -0
- package/dist/presenters/web.js +78 -0
- package/dist/prompts/builder.js +181 -0
- package/dist/prompts/fixer.js +148 -0
- package/dist/prompts/headless.md +97 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/interactive.md +43 -0
- package/dist/prompts/plan.md +41 -0
- package/dist/prompts/planner.js +70 -0
- package/dist/prompts/prompts/builder.md +97 -0
- package/dist/prompts/prompts/fixer.md +100 -0
- package/dist/prompts/prompts/plan.md +41 -0
- package/dist/prompts/prompts/planner.md +41 -0
- package/dist/services/api-client.js +244 -0
- package/dist/services/event-streamer.js +130 -0
- package/dist/ui/App.js +322 -0
- package/dist/ui/components/AuthBanner.js +20 -0
- package/dist/ui/components/AuthDialog.js +32 -0
- package/dist/ui/components/Banner.js +12 -0
- package/dist/ui/components/ExpandableSection.js +17 -0
- package/dist/ui/components/Header.js +49 -0
- package/dist/ui/components/HelpMenu.js +89 -0
- package/dist/ui/components/InputPrompt.js +292 -0
- package/dist/ui/components/MessageList.js +42 -0
- package/dist/ui/components/QueuedMessageDisplay.js +31 -0
- package/dist/ui/components/Scrollable.js +103 -0
- package/dist/ui/components/SessionSelector.js +196 -0
- package/dist/ui/components/StatusBar.js +45 -0
- package/dist/ui/components/messages/AssistantMessage.js +20 -0
- package/dist/ui/components/messages/ErrorMessage.js +26 -0
- package/dist/ui/components/messages/LoadingMessage.js +28 -0
- package/dist/ui/components/messages/ThinkingMessage.js +17 -0
- package/dist/ui/components/messages/TodoMessage.js +44 -0
- package/dist/ui/components/messages/ToolMessage.js +218 -0
- package/dist/ui/components/messages/UserMessage.js +14 -0
- package/dist/ui/contexts/KeypressContext.js +527 -0
- package/dist/ui/contexts/MouseContext.js +98 -0
- package/dist/ui/contexts/SessionContext.js +131 -0
- package/dist/ui/hooks/useAnimatedScrollbar.js +83 -0
- package/dist/ui/hooks/useBatchedScroll.js +22 -0
- package/dist/ui/hooks/useBracketedPaste.js +31 -0
- package/dist/ui/hooks/useFocus.js +50 -0
- package/dist/ui/hooks/useKeypress.js +26 -0
- package/dist/ui/hooks/useModeToggle.js +25 -0
- package/dist/ui/types/auth.js +13 -0
- package/dist/ui/utils/file-completion.js +56 -0
- package/dist/ui/utils/input.js +50 -0
- package/dist/ui/utils/markdown.js +376 -0
- package/dist/ui/utils/mouse.js +189 -0
- package/dist/ui/utils/theme.js +59 -0
- package/dist/utils/banner.js +7 -14
- package/dist/utils/encryption.js +71 -0
- package/dist/utils/events.js +36 -0
- package/dist/utils/keychain-storage.js +120 -0
- package/dist/utils/logger.js +103 -1
- package/dist/utils/node-version.js +1 -3
- package/dist/utils/plan-file.js +75 -0
- package/dist/utils/project-instructions.js +23 -0
- package/dist/utils/rich-logger.js +1 -1
- package/dist/utils/stdio.js +80 -0
- package/dist/utils/summary.js +1 -5
- package/dist/utils/token-storage.js +242 -0
- package/package.json +35 -15
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { config as envConfig } from "../config";
|
|
5
|
+
import { loadProjectInstructions } from "../utils/project-instructions";
|
|
6
|
+
export class CoreAgent {
|
|
7
|
+
presenter;
|
|
8
|
+
abortController = null;
|
|
9
|
+
constructor(presenter) {
|
|
10
|
+
this.presenter = presenter;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Abort the current query execution.
|
|
14
|
+
* This will cancel any running operations including LLM calls and tool executions.
|
|
15
|
+
*/
|
|
16
|
+
abort() {
|
|
17
|
+
if (this.abortController) {
|
|
18
|
+
this.abortController.abort();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async run(config) {
|
|
22
|
+
// Create a fresh AbortController for this run
|
|
23
|
+
this.abortController = new AbortController();
|
|
24
|
+
await this.presenter.onStart(config);
|
|
25
|
+
// Resolve path to Claude Code executable
|
|
26
|
+
const claudeCodePath = await this.resolveClaudeCodePath();
|
|
27
|
+
// Build the prompt
|
|
28
|
+
let prompt = config.task;
|
|
29
|
+
if (config.logs) {
|
|
30
|
+
prompt = `${config.task}\n\nHere are the logs to analyze:\n\`\`\`\n${config.logs}\n\`\`\``;
|
|
31
|
+
}
|
|
32
|
+
// Apply permission mode based on agent mode
|
|
33
|
+
// Plan mode uses 'plan' permission which restricts to read-only tools
|
|
34
|
+
// Build mode uses 'bypassPermissions' for full tool access
|
|
35
|
+
const isPlanMode = config.mode === 'plan';
|
|
36
|
+
const cwd = config.cwd || process.cwd();
|
|
37
|
+
// Only load system prompt for new sessions - resumed sessions already have it
|
|
38
|
+
// This avoids duplicating system prompt tokens on every continuation
|
|
39
|
+
const isResumingSession = !!config.providerSessionId;
|
|
40
|
+
let systemPromptAppend;
|
|
41
|
+
if (!isResumingSession) {
|
|
42
|
+
// Load project instructions from SUPATEST.md
|
|
43
|
+
const projectInstructions = loadProjectInstructions(cwd);
|
|
44
|
+
// Combine system prompts: base prompt + project instructions
|
|
45
|
+
systemPromptAppend = [
|
|
46
|
+
config.systemPromptAppend,
|
|
47
|
+
projectInstructions && `\n\n# Project Instructions (from SUPATEST.md)\n\n${projectInstructions}`,
|
|
48
|
+
].filter(Boolean).join("\n") || undefined;
|
|
49
|
+
}
|
|
50
|
+
const queryOptions = {
|
|
51
|
+
// AbortController for cancellation support
|
|
52
|
+
abortController: this.abortController,
|
|
53
|
+
maxTurns: config.maxIterations,
|
|
54
|
+
cwd,
|
|
55
|
+
model: envConfig.anthropicModelName,
|
|
56
|
+
permissionMode: isPlanMode ? "plan" : "bypassPermissions",
|
|
57
|
+
allowDangerouslySkipPermissions: !isPlanMode,
|
|
58
|
+
pathToClaudeCodeExecutable: claudeCodePath,
|
|
59
|
+
includePartialMessages: true,
|
|
60
|
+
executable: "node",
|
|
61
|
+
// MCP servers for enhanced capabilities
|
|
62
|
+
mcpServers: {
|
|
63
|
+
playwright: {
|
|
64
|
+
command: "npx",
|
|
65
|
+
args: ["-y", "@playwright/mcp@latest"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
// Resume from previous session if providerSessionId is provided
|
|
69
|
+
// This allows the agent to continue conversations with full context
|
|
70
|
+
// Note: Sessions expire after ~30 days due to Anthropic's data retention policy
|
|
71
|
+
...(config.providerSessionId && {
|
|
72
|
+
resume: config.providerSessionId,
|
|
73
|
+
}),
|
|
74
|
+
// Only append system prompt for new sessions - resumed sessions already have context
|
|
75
|
+
...(systemPromptAppend && {
|
|
76
|
+
systemPrompt: {
|
|
77
|
+
type: "preset",
|
|
78
|
+
preset: "claude_code",
|
|
79
|
+
append: systemPromptAppend,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
env: {
|
|
83
|
+
...process.env,
|
|
84
|
+
ANTHROPIC_API_KEY: config.supatestApiKey,
|
|
85
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || "",
|
|
86
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
87
|
+
CLAUDE_CODE_AUTH_TOKEN: "",
|
|
88
|
+
},
|
|
89
|
+
stderr: (msg) => {
|
|
90
|
+
this.presenter.onLog(`[Claude Code stderr] ${msg}`);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
let resultText = "";
|
|
94
|
+
let hasError = false;
|
|
95
|
+
const errors = [];
|
|
96
|
+
let iterations = 0;
|
|
97
|
+
const filesModified = new Set();
|
|
98
|
+
let wasInterrupted = false;
|
|
99
|
+
// Capture the SDK's session_id for future resume capability
|
|
100
|
+
let providerSessionId;
|
|
101
|
+
// Track cumulative token usage
|
|
102
|
+
let totalInputTokens = config.initialTokens || 0;
|
|
103
|
+
let totalOutputTokens = 0;
|
|
104
|
+
// Helper to check if an error indicates an expired/invalid session
|
|
105
|
+
const isSessionExpiredError = (errorMsg) => {
|
|
106
|
+
const expiredPatterns = [
|
|
107
|
+
"no conversation found",
|
|
108
|
+
"session not found",
|
|
109
|
+
"session expired",
|
|
110
|
+
"invalid session",
|
|
111
|
+
];
|
|
112
|
+
const lowerError = errorMsg.toLowerCase();
|
|
113
|
+
return expiredPatterns.some((pattern) => lowerError.includes(pattern));
|
|
114
|
+
};
|
|
115
|
+
// Helper to run the query and process messages
|
|
116
|
+
const runQuery = async (options) => {
|
|
117
|
+
for await (const msg of query({ prompt, options })) {
|
|
118
|
+
// Capture session_id from any message that has it
|
|
119
|
+
// All SDK messages include session_id which we need for resuming
|
|
120
|
+
if ("session_id" in msg && msg.session_id) {
|
|
121
|
+
providerSessionId = msg.session_id;
|
|
122
|
+
}
|
|
123
|
+
if (msg.type === "assistant") {
|
|
124
|
+
iterations++;
|
|
125
|
+
const content = msg.message.content;
|
|
126
|
+
// Extract and accumulate token usage from this turn
|
|
127
|
+
const usage = msg.message.usage;
|
|
128
|
+
if (usage) {
|
|
129
|
+
totalInputTokens += usage.input_tokens || 0;
|
|
130
|
+
totalOutputTokens += usage.output_tokens || 0;
|
|
131
|
+
// Notify presenter of updated token count
|
|
132
|
+
await this.presenter.onUsageUpdate?.(totalInputTokens + totalOutputTokens);
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(content)) {
|
|
135
|
+
for (const block of content) {
|
|
136
|
+
if (block.type === "text") {
|
|
137
|
+
resultText += block.text + "\n";
|
|
138
|
+
await this.presenter.onAssistantText(block.text);
|
|
139
|
+
}
|
|
140
|
+
else if (block.type === "thinking") {
|
|
141
|
+
await this.presenter.onThinking(block.thinking);
|
|
142
|
+
}
|
|
143
|
+
else if (block.type === "tool_use") {
|
|
144
|
+
const toolName = block.name;
|
|
145
|
+
const input = block.input;
|
|
146
|
+
// Track file modifications
|
|
147
|
+
if ((toolName === "Write" || toolName === "Edit") &&
|
|
148
|
+
input?.file_path) {
|
|
149
|
+
filesModified.add(input.file_path);
|
|
150
|
+
}
|
|
151
|
+
await this.presenter.onToolUse(toolName, input, block.id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Notify presenter that the turn is complete
|
|
156
|
+
await this.presenter.onTurnComplete(content);
|
|
157
|
+
}
|
|
158
|
+
else if (msg.type === "result") {
|
|
159
|
+
iterations = msg.num_turns;
|
|
160
|
+
if (msg.subtype === "success") {
|
|
161
|
+
resultText = msg.result || resultText;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
hasError = true;
|
|
165
|
+
if ("errors" in msg && Array.isArray(msg.errors)) {
|
|
166
|
+
errors.push(...msg.errors);
|
|
167
|
+
for (const error of msg.errors) {
|
|
168
|
+
await this.presenter.onError(error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (msg.type === "user") {
|
|
174
|
+
// User message contains tool results - end tool timing
|
|
175
|
+
const userContent = msg.message?.content;
|
|
176
|
+
if (Array.isArray(userContent)) {
|
|
177
|
+
for (const block of userContent) {
|
|
178
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
179
|
+
// Notify presenter of tool result
|
|
180
|
+
if (this.presenter.onToolResult) {
|
|
181
|
+
const resultContent = Array.isArray(block.content)
|
|
182
|
+
? block.content.map((c) => c.text || "").join("\n")
|
|
183
|
+
: typeof block.content === "string"
|
|
184
|
+
? block.content
|
|
185
|
+
: "";
|
|
186
|
+
await this.presenter.onToolResult(block.tool_use_id, resultContent);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
await runQuery(queryOptions);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
199
|
+
// Check if this was an abort (user interrupt)
|
|
200
|
+
// The SDK may throw AbortError or a message containing "aborted"
|
|
201
|
+
const isAbortError = (error instanceof Error && error.name === "AbortError") ||
|
|
202
|
+
errorMessage.toLowerCase().includes("aborted");
|
|
203
|
+
if (isAbortError) {
|
|
204
|
+
wasInterrupted = true;
|
|
205
|
+
}
|
|
206
|
+
else if (config.providerSessionId && isSessionExpiredError(errorMessage)) {
|
|
207
|
+
// If the error indicates an expired session and we were trying to resume,
|
|
208
|
+
// show a user-friendly error message
|
|
209
|
+
const expiredMessage = "Can't continue conversation older than 30 days. Please start a new session.";
|
|
210
|
+
await this.presenter.onError(expiredMessage);
|
|
211
|
+
hasError = true;
|
|
212
|
+
errors.push(expiredMessage);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
await this.presenter.onError(errorMessage);
|
|
216
|
+
hasError = true;
|
|
217
|
+
errors.push(errorMessage);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const result = {
|
|
221
|
+
success: !hasError && errors.length === 0 && !wasInterrupted,
|
|
222
|
+
summary: wasInterrupted ? "Interrupted by user" : resultText || "Task completed",
|
|
223
|
+
filesModified: Array.from(filesModified),
|
|
224
|
+
iterations,
|
|
225
|
+
error: wasInterrupted
|
|
226
|
+
? "Interrupted by user"
|
|
227
|
+
: errors.length > 0
|
|
228
|
+
? errors.join("; ")
|
|
229
|
+
: undefined,
|
|
230
|
+
// Include the provider session ID for resume capability
|
|
231
|
+
providerSessionId,
|
|
232
|
+
};
|
|
233
|
+
await this.presenter.onComplete(result);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
async resolveClaudeCodePath() {
|
|
237
|
+
// Allow override via environment variable
|
|
238
|
+
if (envConfig.claudeCodeExecutablePath) {
|
|
239
|
+
this.presenter.onLog(`Using CLAUDE_CODE_EXECUTABLE_PATH: ${envConfig.claudeCodeExecutablePath}`);
|
|
240
|
+
return envConfig.claudeCodeExecutablePath;
|
|
241
|
+
}
|
|
242
|
+
// Determine binary directory
|
|
243
|
+
const isCompiledBinary = process.execPath && !process.execPath.includes("node");
|
|
244
|
+
let claudeCodePath;
|
|
245
|
+
if (isCompiledBinary) {
|
|
246
|
+
claudeCodePath = join(dirname(process.execPath), "claude-code-cli.js");
|
|
247
|
+
this.presenter.onLog(`Production mode: ${claudeCodePath}`);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const require = createRequire(import.meta.url);
|
|
251
|
+
const sdkPath = require.resolve("@anthropic-ai/claude-agent-sdk/sdk.mjs");
|
|
252
|
+
claudeCodePath = join(dirname(sdkPath), "cli.js");
|
|
253
|
+
this.presenter.onLog(`Development mode: ${claudeCodePath}`);
|
|
254
|
+
}
|
|
255
|
+
// Verify the file exists
|
|
256
|
+
const fs = await import("node:fs/promises");
|
|
257
|
+
try {
|
|
258
|
+
await fs.access(claudeCodePath);
|
|
259
|
+
this.presenter.onLog(`✓ Claude Code CLI found: ${claudeCodePath}`);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
const error = `Claude Code executable not found at: ${claudeCodePath}\n` +
|
|
263
|
+
"For compiled binaries, ensure claude-code-cli.js is in the same directory as the binary.\n" +
|
|
264
|
+
"Set CLAUDE_CODE_EXECUTABLE_PATH environment variable to override.";
|
|
265
|
+
await this.presenter.onError(error);
|
|
266
|
+
throw new Error(error);
|
|
267
|
+
}
|
|
268
|
+
return claudeCodePath;
|
|
269
|
+
}
|
|
270
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,29 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import "./config";
|
|
2
3
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { setupCommand } from "./commands/setup";
|
|
5
|
+
import { config as envConfig } from "./config";
|
|
6
|
+
import { runAgent } from "./modes/headless";
|
|
7
|
+
import { getBanner } from "./utils/banner";
|
|
8
|
+
import { logger } from "./utils/logger";
|
|
9
|
+
import { checkNodeVersion } from "./utils/node-version";
|
|
10
|
+
import { readStdin } from "./utils/stdin";
|
|
11
|
+
import { loadTokenAsync } from "./utils/token-storage";
|
|
8
12
|
const program = new Command();
|
|
13
|
+
// Main run command (default)
|
|
9
14
|
program
|
|
10
|
-
.name("supatest
|
|
15
|
+
.name("supatest")
|
|
11
16
|
.description("AI-powered task automation CLI for CI/CD - fix tests, lint issues, and more")
|
|
12
17
|
.version("1.0.0")
|
|
13
18
|
.argument("[task]", "Task description or prompt for the AI agent")
|
|
14
19
|
.option("-l, --logs <file>", "Path to log file to analyze")
|
|
15
20
|
.option("--stdin", "Read logs from stdin")
|
|
21
|
+
.option("-C, --cwd <path>", "Working directory for the agent", process.cwd())
|
|
16
22
|
.option("-m, --max-iterations <number>", "Maximum number of iterations", "100")
|
|
17
|
-
.option("--api-key <key>", "
|
|
23
|
+
.option("--supatest-api-key <key>", "Supatest API key (or use SUPATEST_API_KEY env)")
|
|
24
|
+
.option("--supatest-api-url <url>", "Supatest API URL (or use SUPATEST_API_URL env, defaults to https://api.supatest.ai)")
|
|
25
|
+
.option("--headless", "Run in headless mode (for CI/CD, minimal output)")
|
|
18
26
|
.option("--verbose", "Enable verbose logging")
|
|
19
27
|
.action(async (task, options) => {
|
|
20
28
|
try {
|
|
21
|
-
//
|
|
29
|
+
// Check Node.js version early
|
|
30
|
+
checkNodeVersion();
|
|
31
|
+
// Determine mode: headless or interactive
|
|
32
|
+
// Headless mode when:
|
|
33
|
+
// - --headless flag is set
|
|
34
|
+
// - stdin is explicitly false (piped input, not undefined)
|
|
35
|
+
const isHeadlessMode = options.headless ||
|
|
36
|
+
process.stdin.isTTY === false;
|
|
37
|
+
// Set verbose and silent modes early
|
|
22
38
|
if (options.verbose) {
|
|
23
39
|
logger.setVerbose(true);
|
|
24
40
|
}
|
|
25
|
-
//
|
|
26
|
-
|
|
41
|
+
// Enable file logging in development
|
|
42
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
43
|
+
logger.enableFileLogging(isDev);
|
|
44
|
+
// In headless mode, suppress banner and interactive output
|
|
45
|
+
// We don't set silent=true here because we still want to see the execution log in CI
|
|
46
|
+
// logger.setSilent(true);
|
|
27
47
|
// Validate task or stdin
|
|
28
48
|
let prompt = task;
|
|
29
49
|
let logs;
|
|
@@ -47,36 +67,103 @@ program
|
|
|
47
67
|
process.exit(1);
|
|
48
68
|
}
|
|
49
69
|
}
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
// In interactive mode, task is optional (will prompt for input)
|
|
71
|
+
// In headless mode, task is required
|
|
72
|
+
if (!prompt && isHeadlessMode) {
|
|
73
|
+
logger.error("Task description is required in headless mode");
|
|
52
74
|
program.help();
|
|
53
75
|
process.exit(1);
|
|
54
76
|
}
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
// Authentication priority:
|
|
78
|
+
// - In CI mode: Only API key (flag or env var)
|
|
79
|
+
// - In non-CI mode: Prefer user token, fallback to API key
|
|
80
|
+
let supatestApiKey;
|
|
81
|
+
const supatestApiUrl = options.supatestApiUrl || envConfig.supatestApiUrl;
|
|
82
|
+
if (isHeadlessMode) {
|
|
83
|
+
// CI/Headless mode: Only use API keys (team sessions)
|
|
84
|
+
supatestApiKey = options.supatestApiKey || envConfig.supatestApiKey;
|
|
85
|
+
if (!supatestApiKey) {
|
|
86
|
+
logger.error("API key required in CI/headless mode. Please either:");
|
|
87
|
+
logger.error(" 1. Set SUPATEST_API_KEY environment variable");
|
|
88
|
+
logger.error(" 2. Use --supatest-api-key option");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Interactive/Local mode: Prefer user token (personal sessions)
|
|
94
|
+
// If no auth available, the interactive UI will prompt for login
|
|
95
|
+
const cliToken = await loadTokenAsync();
|
|
96
|
+
if (cliToken) {
|
|
97
|
+
supatestApiKey = cliToken;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Fallback to API key if no user token
|
|
101
|
+
supatestApiKey = options.supatestApiKey || envConfig.supatestApiKey;
|
|
102
|
+
}
|
|
103
|
+
// Note: supatestApiKey may be undefined here - interactive UI handles login prompts
|
|
104
|
+
}
|
|
105
|
+
// Branch to appropriate mode
|
|
106
|
+
if (isHeadlessMode) {
|
|
107
|
+
// Headless/CI mode: simple output for automation
|
|
108
|
+
// At this point, prompt is guaranteed to be defined due to earlier check
|
|
109
|
+
if (!prompt) {
|
|
110
|
+
throw new Error("Task is required in headless mode");
|
|
111
|
+
}
|
|
112
|
+
logger.raw(getBanner());
|
|
113
|
+
const result = await runAgent({
|
|
114
|
+
task: prompt,
|
|
115
|
+
logs,
|
|
116
|
+
supatestApiKey,
|
|
117
|
+
supatestApiUrl,
|
|
118
|
+
maxIterations: Number.parseInt(options.maxIterations || "100", 10),
|
|
119
|
+
verbose: options.verbose || false,
|
|
120
|
+
cwd: options.cwd,
|
|
121
|
+
systemPromptAppend: envConfig.headlessSystemPrompt,
|
|
122
|
+
});
|
|
123
|
+
process.exit(result.success ? 0 : 1);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// Interactive mode: full UI with Ink + React
|
|
127
|
+
const { runInteractive } = await import("./modes/interactive.js");
|
|
128
|
+
await runInteractive({
|
|
129
|
+
task: prompt || "", // Empty string if no task provided (will use input prompt)
|
|
130
|
+
logs,
|
|
131
|
+
supatestApiKey,
|
|
132
|
+
supatestApiUrl,
|
|
133
|
+
maxIterations: Number.parseInt(options.maxIterations || "100", 10),
|
|
134
|
+
verbose: options.verbose || false,
|
|
135
|
+
cwd: options.cwd,
|
|
136
|
+
systemPromptAppend: envConfig.interactiveSystemPrompt,
|
|
137
|
+
});
|
|
138
|
+
// Interactive mode handles its own exit
|
|
60
139
|
}
|
|
61
|
-
// Display banner
|
|
62
|
-
logger.raw(getBanner());
|
|
63
|
-
// Run the agent
|
|
64
|
-
const result = await runAgent({
|
|
65
|
-
task: prompt,
|
|
66
|
-
logs,
|
|
67
|
-
apiKey,
|
|
68
|
-
maxIterations: Number.parseInt(options.maxIterations || "100", 10),
|
|
69
|
-
verbose: options.verbose || false,
|
|
70
|
-
});
|
|
71
|
-
// Exit with appropriate code
|
|
72
|
-
process.exit(result.success ? 0 : 1);
|
|
73
140
|
}
|
|
74
141
|
catch (error) {
|
|
75
142
|
logger.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
-
if (
|
|
143
|
+
if (error instanceof Error) {
|
|
77
144
|
console.error(error.stack);
|
|
78
145
|
}
|
|
79
146
|
process.exit(1);
|
|
80
147
|
}
|
|
81
148
|
});
|
|
82
|
-
|
|
149
|
+
// Setup command - check prerequisites and install Playwright MCP
|
|
150
|
+
program
|
|
151
|
+
.command("setup")
|
|
152
|
+
.description("Check prerequisites and set up required tools (Node.js, Playwright MCP)")
|
|
153
|
+
.action(async () => {
|
|
154
|
+
try {
|
|
155
|
+
const result = await setupCommand();
|
|
156
|
+
process.exit(result.errors.length === 0 ? 0 : 1);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
logger.error(`Setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// Filter out the standalone '--' separator that pnpm/tsx adds
|
|
164
|
+
// This prevents Commander from treating everything after '--' as positional args
|
|
165
|
+
const filteredArgv = process.argv.filter((arg, index) => {
|
|
166
|
+
// Remove '--' only if it's a standalone argument (not part of a flag like '--verbose')
|
|
167
|
+
return !(arg === '--' && index > 1);
|
|
168
|
+
});
|
|
169
|
+
program.parse(filteredArgv);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { config as envConfig } from "../config";
|
|
3
|
+
import { CoreAgent } from "../core/agent";
|
|
4
|
+
import { CompositePresenter } from "../presenters/composite";
|
|
5
|
+
import { ConsolePresenter } from "../presenters/console";
|
|
6
|
+
import { WebPresenter } from "../presenters/web";
|
|
7
|
+
import { ApiClient } from "../services/api-client";
|
|
8
|
+
import { logger } from "../utils/logger";
|
|
9
|
+
const CLI_VERSION = "0.0.1";
|
|
10
|
+
export async function runAgent(config) {
|
|
11
|
+
// Configure logger
|
|
12
|
+
logger.setVerbose(config.verbose);
|
|
13
|
+
// --- Metadata Display (CLI only) ---
|
|
14
|
+
logger.raw("");
|
|
15
|
+
// Get git branch if available
|
|
16
|
+
let gitBranch = "";
|
|
17
|
+
try {
|
|
18
|
+
const { execSync } = await import("node:child_process");
|
|
19
|
+
gitBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
22
|
+
}).trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Not in a git repo or git not available
|
|
26
|
+
}
|
|
27
|
+
const metadataParts = [
|
|
28
|
+
chalk.dim("Supatest AI ") + chalk.cyan(`v${CLI_VERSION}`),
|
|
29
|
+
chalk.dim("Model: ") + chalk.cyan(envConfig.anthropicModelName),
|
|
30
|
+
];
|
|
31
|
+
if (gitBranch) {
|
|
32
|
+
metadataParts.push(chalk.dim("Branch: ") + chalk.cyan(gitBranch));
|
|
33
|
+
}
|
|
34
|
+
logger.raw(metadataParts.join(chalk.dim(" • ")));
|
|
35
|
+
logger.divider();
|
|
36
|
+
// --- Session & API Setup ---
|
|
37
|
+
const apiUrl = config.supatestApiUrl || "https://api.supatest.ai";
|
|
38
|
+
const apiClient = new ApiClient(apiUrl, config.supatestApiKey);
|
|
39
|
+
let sessionId;
|
|
40
|
+
let webUrl;
|
|
41
|
+
try {
|
|
42
|
+
// Truncate title to 50 characters (backend will auto-generate a better title later)
|
|
43
|
+
const truncatedTitle = config.task.length > 50 ? config.task.slice(0, 50) + "..." : config.task;
|
|
44
|
+
const session = await apiClient.createSession(truncatedTitle, {
|
|
45
|
+
cliVersion: CLI_VERSION,
|
|
46
|
+
cwd: config.cwd || process.cwd(),
|
|
47
|
+
});
|
|
48
|
+
sessionId = session.sessionId;
|
|
49
|
+
webUrl = session.webUrl;
|
|
50
|
+
logger.raw("");
|
|
51
|
+
logger.divider();
|
|
52
|
+
logger.raw(chalk.white.bold("View session live: ") +
|
|
53
|
+
chalk.cyan.underline(webUrl));
|
|
54
|
+
logger.divider();
|
|
55
|
+
logger.raw("");
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.warn(`Failed to create session on backend: ${error.message}`);
|
|
59
|
+
logger.warn("Continuing without web streaming...");
|
|
60
|
+
}
|
|
61
|
+
// --- Environment Setup ---
|
|
62
|
+
// Build base URL with session ID embedded for the proxy
|
|
63
|
+
let baseUrl = `${apiUrl}/public`;
|
|
64
|
+
if (sessionId) {
|
|
65
|
+
baseUrl = `${apiUrl}/v1/sessions/${sessionId}/anthropic`;
|
|
66
|
+
}
|
|
67
|
+
// Set environment variables for the SDK to pick up (via CoreAgent)
|
|
68
|
+
process.env.ANTHROPIC_BASE_URL = baseUrl;
|
|
69
|
+
process.env.ANTHROPIC_API_KEY = config.supatestApiKey;
|
|
70
|
+
// --- Agent Execution ---
|
|
71
|
+
const presenters = [];
|
|
72
|
+
// 1. Console Presenter (stdout)
|
|
73
|
+
presenters.push(new ConsolePresenter({ verbose: config.verbose }));
|
|
74
|
+
// 2. Web Presenter (streaming)
|
|
75
|
+
if (sessionId) {
|
|
76
|
+
presenters.push(new WebPresenter(apiClient, sessionId));
|
|
77
|
+
}
|
|
78
|
+
const compositePresenter = new CompositePresenter(presenters);
|
|
79
|
+
const agent = new CoreAgent(compositePresenter);
|
|
80
|
+
try {
|
|
81
|
+
const result = await agent.run(config);
|
|
82
|
+
// Store the provider session ID for future resume capability
|
|
83
|
+
// This allows follow-up messages to continue the conversation with full context
|
|
84
|
+
if (sessionId && result.providerSessionId) {
|
|
85
|
+
try {
|
|
86
|
+
await apiClient.updateSession(sessionId, {
|
|
87
|
+
providerSessionId: result.providerSessionId,
|
|
88
|
+
});
|
|
89
|
+
logger.debug(`Stored provider session ID for resume capability`);
|
|
90
|
+
}
|
|
91
|
+
catch (updateError) {
|
|
92
|
+
// Non-critical - log but don't fail
|
|
93
|
+
logger.warn(`Failed to store provider session ID: ${updateError.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Display web URL again at completion
|
|
97
|
+
if (webUrl) {
|
|
98
|
+
logger.raw("");
|
|
99
|
+
logger.divider();
|
|
100
|
+
logger.raw(chalk.white.bold("View session: ") +
|
|
101
|
+
chalk.cyan.underline(webUrl));
|
|
102
|
+
logger.divider();
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
108
|
+
// Error is already logged by presenter.onError
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
summary: `Failed: ${errorMessage}`,
|
|
112
|
+
filesModified: [],
|
|
113
|
+
iterations: 0,
|
|
114
|
+
error: errorMessage,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|