astrabot 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/README.md +411 -0
- package/ai/ai.config.ts +27 -0
- package/ai/auto-retry.ts +117 -0
- package/ai/config-loader.ts +132 -0
- package/ai/index.ts +4 -0
- package/ai/retry-prompt.ts +30 -0
- package/bin/astra +2 -0
- package/core/retry/error-classifier.ts +208 -0
- package/core/retry/index.ts +29 -0
- package/core/retry/retry-config.ts +142 -0
- package/core/retry/retry-engine.ts +215 -0
- package/game/index.html +573 -0
- package/game/neon-breaker.html +1037 -0
- package/index.ts +140 -0
- package/modes/agent/action-tracker.ts +47 -0
- package/modes/agent/agent-tools.ts +338 -0
- package/modes/agent/approval.ts +184 -0
- package/modes/agent/diff-view.ts +34 -0
- package/modes/agent/orchestrator.ts +234 -0
- package/modes/agent/tool-executor.ts +993 -0
- package/modes/agent/types.ts +68 -0
- package/modes/ask/orchestrator.ts +230 -0
- package/modes/auto.ts +88 -0
- package/modes/cli.ts +43 -0
- package/modes/multi/agent-pool-manager.ts +337 -0
- package/modes/multi/examples.ts +441 -0
- package/modes/multi/message-broker.ts +179 -0
- package/modes/multi/multi-agent-orchestrator.ts +891 -0
- package/modes/multi/orchestrator.ts +414 -0
- package/modes/multi/types.ts +245 -0
- package/modes/multi/workflow-builder.ts +569 -0
- package/modes/plan/orchestrator.ts +198 -0
- package/modes/plan/planner.ts +121 -0
- package/modes/plan/selection.ts +43 -0
- package/modes/plan/types.ts +13 -0
- package/modes/plan/web-tools.ts +132 -0
- package/modes/setup.ts +210 -0
- package/package.json +62 -0
- package/session/index.ts +45 -0
- package/session/session-context.ts +188 -0
- package/session/session-manager.ts +374 -0
- package/session/session-tools.ts +109 -0
- package/session/store.ts +278 -0
- package/tsconfig.json +30 -0
- package/tui/spinner.ts +182 -0
- package/tui/terminal-md.ts +17 -0
- package/tui/wakeup.ts +231 -0
package/index.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { runWakeup, printBanner } from "./tui/wakeup"; // Imported printBanner for the breathing effect
|
|
5
|
+
import { runSetup } from "./modes/setup";
|
|
6
|
+
import pkg from "./package.json" with { type: "json" };
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import figlet from "figlet"; // Imported figlet for the arcade banner generation
|
|
12
|
+
import { confirm, isCancel, select } from "@clack/prompts";
|
|
13
|
+
import { exec } from "child_process";
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name("astra")
|
|
19
|
+
.description("Astra CLI — AI-native development companion")
|
|
20
|
+
.version(pkg.version, "-v, --version", "Output the current version");
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command("wakeup")
|
|
24
|
+
.description("Show the banner and pick interaction mode")
|
|
25
|
+
.action(async () => {
|
|
26
|
+
await runWakeup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command("setup")
|
|
31
|
+
.description("Configure API keys and settings (~/.astra/.env)")
|
|
32
|
+
.action(async () => {
|
|
33
|
+
await runSetup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.command("play")
|
|
38
|
+
.description("Launch an undocumented workspace arcade easter egg mini-game")
|
|
39
|
+
.action(async () => {
|
|
40
|
+
// Generate the baseline ASCII asset for the arcade room
|
|
41
|
+
let arcadeAscii = "";
|
|
42
|
+
try {
|
|
43
|
+
arcadeAscii = figlet.textSync("ARCADE", {
|
|
44
|
+
font: "ANSI Shadow",
|
|
45
|
+
horizontalLayout: "fitted",
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
arcadeAscii = figlet.textSync("ARCADE", { font: "Standard" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Play the full breathing banner animation with the twinkling stars
|
|
52
|
+
await printBanner(arcadeAscii);
|
|
53
|
+
|
|
54
|
+
console.log(chalk.bold.magenta(" 🎮 Astra Arcade Workspace Matrix\n"));
|
|
55
|
+
|
|
56
|
+
// 1. Interactive Game Selector Prompt
|
|
57
|
+
const gameChoice = await select({
|
|
58
|
+
message: "Choose an arcade game to launch:",
|
|
59
|
+
options: [
|
|
60
|
+
{ value: "index.html", label: "Retro Snake Classic" },
|
|
61
|
+
{ value: "neon-breaker.html", label: "Neon Brick Breaker" },
|
|
62
|
+
{ value: "exit", label: "Exit"}
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (isCancel(gameChoice) || gameChoice==="exit") {
|
|
67
|
+
console.log(chalk.dim(" Arcade closed.\n"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolve the internal path safely relative to the executing workspace binary bundle
|
|
72
|
+
const gameFilePath = path.join(import.meta.dir, "game", gameChoice);
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(gameFilePath)) {
|
|
75
|
+
console.log(chalk.red(`\n ✗ Asset mismatch: Game asset not found at ${gameFilePath}\n`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PORT = 4321;
|
|
80
|
+
const localUrl = `http://localhost:${PORT}`;
|
|
81
|
+
|
|
82
|
+
// 2. Spawn a background static asset file server using Bun's fast native engine
|
|
83
|
+
try {
|
|
84
|
+
Bun.serve({
|
|
85
|
+
port: PORT,
|
|
86
|
+
fetch(req) {
|
|
87
|
+
return new Response(Bun.file(gameFilePath));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log(chalk.green(`\n ✓ Local arcade matrix listening live at ${localUrl}`));
|
|
92
|
+
console.log(chalk.dim(" Press [Ctrl + C] in this terminal session to close down server logs.\n"));
|
|
93
|
+
|
|
94
|
+
// 3. Automatically spawn their default system web browser target
|
|
95
|
+
const startCmd =
|
|
96
|
+
process.platform === "win32" ? "start" :
|
|
97
|
+
process.platform === "darwin" ? "open" : "xdg-open";
|
|
98
|
+
|
|
99
|
+
exec(`${startCmd} ${localUrl}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(chalk.red(`\n ✗ Port initialization blocked: ${(err as Error).message}\n`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
program
|
|
106
|
+
.command("reset")
|
|
107
|
+
.description("Completely remove all localized configurations, sessions, and credentials cached by Astra")
|
|
108
|
+
.action(async () => {
|
|
109
|
+
console.log(chalk.bold.yellow("\n ⚠ Danger Zone"));
|
|
110
|
+
|
|
111
|
+
const targetDir = path.join(os.homedir(), ".astra");
|
|
112
|
+
|
|
113
|
+
if (!fs.existsSync(targetDir)) {
|
|
114
|
+
console.log(chalk.dim(" No active data store or environment parameters discovered at ~/.astra.\n"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const authorized = await confirm({
|
|
119
|
+
message: "Are you absolutely sure you want to purge all stored configurations, environments, and historical run data?",
|
|
120
|
+
initialValue: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (isCancel(authorized) || !authorized) {
|
|
124
|
+
console.log(chalk.dim(" Reset aborted.\n"));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
130
|
+
console.log(chalk.green(`\n ✓ Local cache wiped successfully from ${targetDir}`));
|
|
131
|
+
console.log(chalk.dim(" To completely remove the companion binary, run: ") + chalk.cyan("npm uninstall -g astra-dev-cli\n"));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(chalk.red(`\n ✗ Failed to clear cache directory: ${(error as Error).message}\n`));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await program.parseAsync(process.argv);
|
|
138
|
+
|
|
139
|
+
// Export programmatic version for custom tool diagnostics or bug report attachments
|
|
140
|
+
export const ASTRA_VERSION = pkg.version;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ActionLog, ActionStatus } from "./types";
|
|
2
|
+
import { isMutationType } from "./types";
|
|
3
|
+
|
|
4
|
+
export class ActionTracker{
|
|
5
|
+
private actions:ActionLog[] = []
|
|
6
|
+
private counter = 0
|
|
7
|
+
|
|
8
|
+
log(
|
|
9
|
+
entry: Omit<ActionLog, 'id' | 'timestamp'> & {
|
|
10
|
+
id?: string;
|
|
11
|
+
timestamp?: Date;
|
|
12
|
+
},
|
|
13
|
+
):ActionLog {
|
|
14
|
+
const action:ActionLog = {
|
|
15
|
+
id: entry.id ?? `action_${this.counter++}`,
|
|
16
|
+
timestamp: entry.timestamp ?? new Date(),
|
|
17
|
+
type: entry.type,
|
|
18
|
+
path: entry.path,
|
|
19
|
+
details: {...entry.details},
|
|
20
|
+
status: entry.status,
|
|
21
|
+
userApproved: entry.userApproved
|
|
22
|
+
}
|
|
23
|
+
this.actions.push(action)
|
|
24
|
+
return action
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getActions():readonly ActionLog[] {
|
|
28
|
+
return this.actions
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getPendingMutations():ActionLog[] {
|
|
32
|
+
return this.actions.filter(
|
|
33
|
+
(a)=>isMutationType(a.type) && a.status==="pending"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getPendingMutationsForPath(path: string): ActionLog[] {
|
|
38
|
+
return this.getPendingMutations().filter((a) => a.path === path)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
updateStatus(id: string, status: ActionStatus, userApproved?: boolean): void {
|
|
42
|
+
const a = this.actions.find((x)=>x.id===id)
|
|
43
|
+
if(!a) return
|
|
44
|
+
a.status = status
|
|
45
|
+
if(userApproved!==undefined) a.userApproved = userApproved
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import {tool} from 'ai'
|
|
2
|
+
import {z} from 'zod'
|
|
3
|
+
import type { ToolExecutor } from './tool-executor'
|
|
4
|
+
|
|
5
|
+
interface AgentToolHooks {
|
|
6
|
+
afterCreateFile?: (path: string) => Promise<string | void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createAgentTools(executor: ToolExecutor, hooks: AgentToolHooks = {}){
|
|
10
|
+
return {
|
|
11
|
+
|
|
12
|
+
read_file: tool({
|
|
13
|
+
description: "Read a text file from the workspace. Use a path relative to the project root.",
|
|
14
|
+
inputSchema: z.object({
|
|
15
|
+
path: z.string().describe("Relative file path")
|
|
16
|
+
}),
|
|
17
|
+
execute: async({path:p}) => executor.readFile(p)
|
|
18
|
+
}),
|
|
19
|
+
|
|
20
|
+
create_file: tool({
|
|
21
|
+
description:
|
|
22
|
+
"Stage creation of a new file (not written until the user approves).",
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
path: z.string(),
|
|
25
|
+
content: z.string(),
|
|
26
|
+
}),
|
|
27
|
+
execute: async ({ path: p, content }) => {
|
|
28
|
+
const staged = executor.createFile(p, content)
|
|
29
|
+
const followUp = await hooks.afterCreateFile?.(executor.normalizePath(p))
|
|
30
|
+
return followUp ?? staged
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
modify_file: tool({
|
|
35
|
+
description:
|
|
36
|
+
"Stage a full-file replacement for an existing file (pending approval).",
|
|
37
|
+
inputSchema: z.object({
|
|
38
|
+
path: z.string(),
|
|
39
|
+
content: z.string().describe("Complete new file contents"),
|
|
40
|
+
}),
|
|
41
|
+
execute: async ({ path: p, content }) => executor.modifyFile(p, content),
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
delete_file: tool({
|
|
45
|
+
description: "Stage deletion of a file (pending approval).",
|
|
46
|
+
inputSchema: z.object({
|
|
47
|
+
path: z.string(),
|
|
48
|
+
}),
|
|
49
|
+
execute: async ({ path: p }) => executor.deleteFile(p),
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
create_folder: tool({
|
|
53
|
+
description:
|
|
54
|
+
"Stage creation of a directory tree (pending approval). Uses mkdir -p on apply.",
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
path: z.string().describe("Relative directory path"),
|
|
57
|
+
}),
|
|
58
|
+
execute: async ({ path: p }) => executor.createFolder(p),
|
|
59
|
+
}),
|
|
60
|
+
|
|
61
|
+
list_files: tool({
|
|
62
|
+
description: "List files and directories under a path.",
|
|
63
|
+
inputSchema: z.object({
|
|
64
|
+
path: z.string(),
|
|
65
|
+
recursive: z.boolean().optional().default(false),
|
|
66
|
+
}),
|
|
67
|
+
execute: async ({ path: p, recursive }) =>
|
|
68
|
+
executor.listFiles(p, recursive),
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
search_files: tool({
|
|
72
|
+
description:
|
|
73
|
+
'Find files matching a glob pattern (e.g. "*.ts", "**/*.md"). Optional content substring filter.',
|
|
74
|
+
inputSchema: z.object({
|
|
75
|
+
root: z.string().describe("Directory to search, relative to root"),
|
|
76
|
+
pattern: z
|
|
77
|
+
.string()
|
|
78
|
+
.describe("Glob-like pattern using * and ** (forward slashes)"),
|
|
79
|
+
content_contains: z.string().optional(),
|
|
80
|
+
}),
|
|
81
|
+
execute: async ({ root, pattern, content_contains }) =>
|
|
82
|
+
executor.searchFiles(root, pattern, content_contains),
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
analyze_codebase: tool({
|
|
86
|
+
description:
|
|
87
|
+
"Summarize structure: file counts, size, extensions. Read-only.",
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
path: z.string().default("."),
|
|
90
|
+
}),
|
|
91
|
+
execute: async ({ path: p }) => executor.analyzeCodebase(p),
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
read_multiple_files: tool({
|
|
95
|
+
description:
|
|
96
|
+
"Read multiple files in a single tool call. Each file is individually logged to the action trail.",
|
|
97
|
+
inputSchema: z.object({
|
|
98
|
+
paths: z.array(z.string())
|
|
99
|
+
}),
|
|
100
|
+
execute: async ({ paths }) =>
|
|
101
|
+
executor.readMultipleFiles(paths)
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
grep: tool({
|
|
105
|
+
description:
|
|
106
|
+
"Search file contents using a text query.",
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
root: z.string().default("."),
|
|
109
|
+
query: z.string(),
|
|
110
|
+
caseSensitive: z.boolean().default(false)
|
|
111
|
+
}),
|
|
112
|
+
execute: async (args) =>
|
|
113
|
+
executor.grep(args)
|
|
114
|
+
}),
|
|
115
|
+
|
|
116
|
+
replace_in_file: tool({
|
|
117
|
+
description:
|
|
118
|
+
"Replace text inside a file while preserving the rest.",
|
|
119
|
+
inputSchema: z.object({
|
|
120
|
+
path: z.string(),
|
|
121
|
+
search: z.string(),
|
|
122
|
+
replace: z.string()
|
|
123
|
+
}),
|
|
124
|
+
execute: async ({path, search, replace}) =>
|
|
125
|
+
executor.replaceInFile(path, search, replace)
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
append_to_file: tool({
|
|
129
|
+
description:
|
|
130
|
+
"Append content to the end of a file.",
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
path: z.string(),
|
|
133
|
+
content: z.string()
|
|
134
|
+
}),
|
|
135
|
+
execute: async ({ path, content }) =>
|
|
136
|
+
executor.appendToFile( path, content )
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
insert_at_line: tool({
|
|
140
|
+
description:
|
|
141
|
+
"Insert content at a specific line.",
|
|
142
|
+
inputSchema: z.object({
|
|
143
|
+
path: z.string(),
|
|
144
|
+
line: z.number(),
|
|
145
|
+
content: z.string()
|
|
146
|
+
}),
|
|
147
|
+
execute: async ({path, line, content}) =>
|
|
148
|
+
executor.insertAtLine(path, line, content)
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
run_command: tool({
|
|
152
|
+
description:
|
|
153
|
+
"Run a command immediately and capture output.",
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
command: z.string(),
|
|
156
|
+
cwd: z.string().optional()
|
|
157
|
+
}),
|
|
158
|
+
execute: async ({command, cwd}) =>
|
|
159
|
+
executor.runCommand(command, cwd)
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
run_background_command: tool({
|
|
163
|
+
description:
|
|
164
|
+
"Start a long-running process.",
|
|
165
|
+
inputSchema: z.object({
|
|
166
|
+
command: z.string(),
|
|
167
|
+
cwd: z.string().optional()
|
|
168
|
+
}),
|
|
169
|
+
execute: async (args) =>
|
|
170
|
+
executor.runBackgroundCommand(args)
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
git_status: tool({
|
|
174
|
+
description:
|
|
175
|
+
"Get git status.",
|
|
176
|
+
inputSchema: z.object({}),
|
|
177
|
+
execute: async () =>
|
|
178
|
+
executor.gitStatus()
|
|
179
|
+
}),
|
|
180
|
+
|
|
181
|
+
git_diff: tool({
|
|
182
|
+
description:
|
|
183
|
+
"Get git diff.",
|
|
184
|
+
inputSchema: z.object({
|
|
185
|
+
staged: z.boolean().default(false)
|
|
186
|
+
}),
|
|
187
|
+
execute: async ({ staged }) =>
|
|
188
|
+
executor.gitDiff(staged)
|
|
189
|
+
}),
|
|
190
|
+
|
|
191
|
+
git_log: tool({
|
|
192
|
+
description:
|
|
193
|
+
"Get recent commits.",
|
|
194
|
+
inputSchema: z.object({
|
|
195
|
+
limit: z.number().default(20)
|
|
196
|
+
}),
|
|
197
|
+
execute: async ({ limit }) =>
|
|
198
|
+
executor.gitLog(limit)
|
|
199
|
+
}),
|
|
200
|
+
|
|
201
|
+
run_tests: tool({
|
|
202
|
+
description:
|
|
203
|
+
"Run the project's test suite.",
|
|
204
|
+
inputSchema: z.object({
|
|
205
|
+
filter: z.string().optional()
|
|
206
|
+
}),
|
|
207
|
+
execute: async ({ filter }) =>
|
|
208
|
+
executor.runTests(filter)
|
|
209
|
+
}),
|
|
210
|
+
|
|
211
|
+
run_test_file: tool({
|
|
212
|
+
description:
|
|
213
|
+
"Run a specific test file.",
|
|
214
|
+
inputSchema: z.object({
|
|
215
|
+
path: z.string()
|
|
216
|
+
}),
|
|
217
|
+
execute: async ({ path }) =>
|
|
218
|
+
executor.runTestFile(path)
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
lint_project: tool({
|
|
222
|
+
description:
|
|
223
|
+
"Run linting.",
|
|
224
|
+
inputSchema: z.object({}),
|
|
225
|
+
execute: async () =>
|
|
226
|
+
executor.lintProject()
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
format_project: tool({
|
|
230
|
+
description:
|
|
231
|
+
"Run formatting.",
|
|
232
|
+
inputSchema: z.object({}),
|
|
233
|
+
execute: async () =>
|
|
234
|
+
executor.formatProject()
|
|
235
|
+
}),
|
|
236
|
+
|
|
237
|
+
detect_framework: tool({
|
|
238
|
+
description:
|
|
239
|
+
"Detect framework, package manager and language.",
|
|
240
|
+
inputSchema: z.object({}),
|
|
241
|
+
execute: async () =>
|
|
242
|
+
executor.detectFramework()
|
|
243
|
+
}),
|
|
244
|
+
|
|
245
|
+
read_package_json: tool({
|
|
246
|
+
description:
|
|
247
|
+
"Read package.json summary.",
|
|
248
|
+
inputSchema: z.object({}),
|
|
249
|
+
execute: async () =>
|
|
250
|
+
executor.readPackageJson()
|
|
251
|
+
}),
|
|
252
|
+
|
|
253
|
+
web_search: tool({
|
|
254
|
+
description:
|
|
255
|
+
"Search the web for documentation.",
|
|
256
|
+
inputSchema: z.object({
|
|
257
|
+
query: z.string()
|
|
258
|
+
}),
|
|
259
|
+
execute: async ({ query }) =>
|
|
260
|
+
executor.webSearch(query)
|
|
261
|
+
}),
|
|
262
|
+
|
|
263
|
+
fetch_url: tool({
|
|
264
|
+
description:
|
|
265
|
+
"Fetch and summarize a URL.",
|
|
266
|
+
inputSchema: z.object({
|
|
267
|
+
url: z.string()
|
|
268
|
+
}),
|
|
269
|
+
execute: async ({ url }) =>
|
|
270
|
+
executor.fetchUrl(url)
|
|
271
|
+
}),
|
|
272
|
+
|
|
273
|
+
create_plan: tool({
|
|
274
|
+
description:
|
|
275
|
+
"Create a task execution plan.",
|
|
276
|
+
inputSchema: z.object({
|
|
277
|
+
goal: z.string()
|
|
278
|
+
}),
|
|
279
|
+
execute: async ({ goal }) =>
|
|
280
|
+
executor.createPlan(goal)
|
|
281
|
+
}),
|
|
282
|
+
|
|
283
|
+
get_plan: tool({
|
|
284
|
+
description:
|
|
285
|
+
"Retrieve current plan.",
|
|
286
|
+
inputSchema: z.object({}),
|
|
287
|
+
execute: async () =>
|
|
288
|
+
executor.getPlan()
|
|
289
|
+
}),
|
|
290
|
+
|
|
291
|
+
show_pending_changes: tool({
|
|
292
|
+
description:
|
|
293
|
+
"Show staged file operations (read-only display - does NOT apply changes). Use this to review what would be modified before user approval.",
|
|
294
|
+
inputSchema: z.object({}),
|
|
295
|
+
execute: async () =>
|
|
296
|
+
executor.showPendingChanges()
|
|
297
|
+
}),
|
|
298
|
+
|
|
299
|
+
// ❌ REMOVED: apply_changes
|
|
300
|
+
// This tool has been removed because applying changes must go through
|
|
301
|
+
// the runApprovalFlow() in orchestrator.ts which requires explicit
|
|
302
|
+
// user approval. Agents should never auto-apply changes without
|
|
303
|
+
// user consent.
|
|
304
|
+
|
|
305
|
+
discard_changes: tool({
|
|
306
|
+
description:
|
|
307
|
+
"Discard all staged operations (useful if you want to start over).",
|
|
308
|
+
inputSchema: z.object({}),
|
|
309
|
+
execute: async () =>
|
|
310
|
+
executor.discardChanges()
|
|
311
|
+
}),
|
|
312
|
+
|
|
313
|
+
execute_shell: tool({
|
|
314
|
+
description:
|
|
315
|
+
"Queue a shell command to run in the workspace after user approval. Use with care.",
|
|
316
|
+
inputSchema: z.object({
|
|
317
|
+
command: z.string().describe("Single command; runs with shell: true"),
|
|
318
|
+
}),
|
|
319
|
+
execute: async ({ command }) => executor.queueShell(command),
|
|
320
|
+
}),
|
|
321
|
+
|
|
322
|
+
list_skills: tool({
|
|
323
|
+
description:
|
|
324
|
+
"List absolute paths to SKILL.md files under configured skill directories (Cursor / Claude).",
|
|
325
|
+
inputSchema: z.object({}),
|
|
326
|
+
execute: async () => executor.listSkills(),
|
|
327
|
+
}),
|
|
328
|
+
|
|
329
|
+
read_skill: tool({
|
|
330
|
+
description:
|
|
331
|
+
"Read a SKILL.md file. Path must be absolute and under skill roots, or use a path returned by list_skills.",
|
|
332
|
+
inputSchema: z.object({
|
|
333
|
+
path: z.string(),
|
|
334
|
+
}),
|
|
335
|
+
execute: async ({ path: p }) => executor.readSkill(p),
|
|
336
|
+
}),
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ActionTracker } from "./action-tracker";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import {select, isCancel} from '@clack/prompts'
|
|
4
|
+
import type { ActionLog } from "./types";
|
|
5
|
+
import { composeBeforeAfter, formatPatch } from "./diff-view";
|
|
6
|
+
import { renderTerminalMarkdown } from "../../tui/terminal-md";
|
|
7
|
+
|
|
8
|
+
interface ReviewGroup{
|
|
9
|
+
label:string;
|
|
10
|
+
actionIds:string[],
|
|
11
|
+
patch:string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ApprovalFlowOptions {
|
|
15
|
+
paths?: string[];
|
|
16
|
+
skipBatchPrompt?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function groupPending(pending: ActionLog[]): ReviewGroup[] {
|
|
20
|
+
const byPath = new Map<string, ActionLog[]>();
|
|
21
|
+
const shells: ActionLog[] = [];
|
|
22
|
+
|
|
23
|
+
for (const a of pending) {
|
|
24
|
+
if (a.type === "tool_execute") {
|
|
25
|
+
shells.push(a);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const key = a.path;
|
|
29
|
+
if (!byPath.has(key)) byPath.set(key, []);
|
|
30
|
+
byPath.get(key)!.push(a);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const groups: ReviewGroup[] = [];
|
|
34
|
+
|
|
35
|
+
const pathEntries = [...byPath.entries()].sort(([a], [b]) =>
|
|
36
|
+
a.localeCompare(b),
|
|
37
|
+
);
|
|
38
|
+
for (const [p, acts] of pathEntries) {
|
|
39
|
+
const sorted = acts.sort(
|
|
40
|
+
(x, y) => x.timestamp.getTime() - y.timestamp.getTime(),
|
|
41
|
+
);
|
|
42
|
+
const ids = sorted.map((x) => x.id);
|
|
43
|
+
|
|
44
|
+
if (sorted.every((x) => x.type === "folder_create")) {
|
|
45
|
+
groups.push({
|
|
46
|
+
label: `Create folder: ${p}`,
|
|
47
|
+
actionIds: ids,
|
|
48
|
+
patch: null,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { before, after } = composeBeforeAfter(sorted);
|
|
54
|
+
const patch = formatPatch(p, before, after);
|
|
55
|
+
const kinds = [...new Set(sorted.map((x) => x.type))].join(", ");
|
|
56
|
+
groups.push({ label: `${p} (${kinds})`, actionIds: ids, patch });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const s of shells) {
|
|
60
|
+
groups.push({
|
|
61
|
+
label: `Shell: ${s.details.command ?? "(no command)"}`,
|
|
62
|
+
actionIds: [s.id],
|
|
63
|
+
patch: null,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the approval flow for staged changes.
|
|
72
|
+
*
|
|
73
|
+
* This function:
|
|
74
|
+
* 1. Checks if there are any pending changes
|
|
75
|
+
* 2. If none, returns false (nothing to approve)
|
|
76
|
+
* 3. If yes, prompts user to approve all, review individually, or cancel
|
|
77
|
+
* 4. Updates tracker with user's approval decisions
|
|
78
|
+
* 5. Returns true if ANY changes were approved, false if all rejected/cancelled
|
|
79
|
+
*
|
|
80
|
+
* @param tracker ActionTracker with pending mutations
|
|
81
|
+
* @returns true if user approved any changes, false otherwise
|
|
82
|
+
*/
|
|
83
|
+
export async function runApprovalFlow(
|
|
84
|
+
tracker: ActionTracker,
|
|
85
|
+
options: ApprovalFlowOptions = {},
|
|
86
|
+
):Promise<boolean>{
|
|
87
|
+
const pathSet = options.paths ? new Set(options.paths) : null
|
|
88
|
+
const pending = pathSet
|
|
89
|
+
? tracker.getPendingMutations().filter((a) => pathSet.has(a.path))
|
|
90
|
+
: tracker.getPendingMutations()
|
|
91
|
+
|
|
92
|
+
// No changes to review
|
|
93
|
+
if(pending.length === 0){
|
|
94
|
+
console.log(chalk.dim('\nNo staged file, folder or shell changes to review.\n'))
|
|
95
|
+
return false // ✓ Correct: nothing to approve
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if(!options.skipBatchPrompt){
|
|
99
|
+
// Ask user how to proceed
|
|
100
|
+
const choice = await select({
|
|
101
|
+
message: "Apply staged changes?",
|
|
102
|
+
options: [
|
|
103
|
+
{value: "all", label: "Approve and apply all"},
|
|
104
|
+
{value: "select", label: "Review one by one"},
|
|
105
|
+
{value: "cancel", label: "Cancel"},
|
|
106
|
+
]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// User cancelled
|
|
110
|
+
if(isCancel(choice) || choice === "cancel"){
|
|
111
|
+
// Mark all as rejected
|
|
112
|
+
for(const a of pending){
|
|
113
|
+
tracker.updateStatus(a.id, "rejected", false)
|
|
114
|
+
}
|
|
115
|
+
return false // ✓ Correct: user rejected all
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// User selected "Approve all" - approve everything and return immediately
|
|
119
|
+
if(choice === "all"){
|
|
120
|
+
for(const a of pending){
|
|
121
|
+
tracker.updateStatus(a.id, "approved", true)
|
|
122
|
+
}
|
|
123
|
+
return true // ✓ IMPORTANT: return immediately without asking about each change
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// User selected "Review one by one"
|
|
128
|
+
// Groups changes by file for easier review
|
|
129
|
+
const groups = groupPending(pending);
|
|
130
|
+
|
|
131
|
+
for(const g of groups){
|
|
132
|
+
// Keep asking about this group until user makes a choice
|
|
133
|
+
while(true){
|
|
134
|
+
const opt = await select({
|
|
135
|
+
message: chalk.bold(g.label),
|
|
136
|
+
options: [
|
|
137
|
+
{ value: "accept", label: "Accept" },
|
|
138
|
+
{ value: "diff", label: "Show diff", hint: g.patch ? "" : "N/A" },
|
|
139
|
+
{ value: "reject", label: "Reject" },
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// User hit Ctrl+C during review
|
|
144
|
+
if(isCancel(opt)){
|
|
145
|
+
for(const a of pending) {
|
|
146
|
+
tracker.updateStatus(a.id, "rejected", false)
|
|
147
|
+
}
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// User wants to see the diff
|
|
152
|
+
if(opt === "diff"){
|
|
153
|
+
if (g.patch) {
|
|
154
|
+
console.log(
|
|
155
|
+
"\n" +
|
|
156
|
+
renderTerminalMarkdown("```diff\n" + g.patch + "\n```\n") +
|
|
157
|
+
"\n",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// ✓ Loop continues, ask again for this group
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// User accepted or rejected this group
|
|
165
|
+
// opt === "accept" or opt === "reject"
|
|
166
|
+
for(const id of g.actionIds){
|
|
167
|
+
tracker.updateStatus(
|
|
168
|
+
id,
|
|
169
|
+
opt === "accept" ? "approved" : "rejected",
|
|
170
|
+
opt === "accept"
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ✓ Break inner loop, move to next group
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ✓ Return true only if user approved ANY changes
|
|
180
|
+
// If user rejected all, this returns false (nothing gets applied)
|
|
181
|
+
return pending.some((a) =>
|
|
182
|
+
tracker.getActions().some((x) => x.id === a.id && x.status === "approved"),
|
|
183
|
+
)
|
|
184
|
+
}
|