@oh-my-pi/pi-coding-agent 1.337.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/CHANGELOG.md +1228 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +637 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +81 -0
- package/src/cli/args.ts +246 -0
- package/src/cli/file-processor.ts +72 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +650 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli.ts +10 -0
- package/src/commands/init.md +20 -0
- package/src/config.ts +159 -0
- package/src/core/agent-session.ts +1900 -0
- package/src/core/auth-storage.ts +236 -0
- package/src/core/bash-executor.ts +196 -0
- package/src/core/compaction/branch-summarization.ts +343 -0
- package/src/core/compaction/compaction.ts +742 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +154 -0
- package/src/core/custom-tools/index.ts +21 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +169 -0
- package/src/core/custom-tools/wrapper.ts +28 -0
- package/src/core/exec.ts +129 -0
- package/src/core/export-html/index.ts +211 -0
- package/src/core/export-html/template.css +781 -0
- package/src/core/export-html/template.html +54 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +312 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +99 -0
- package/src/core/hooks/types.ts +773 -0
- package/src/core/index.ts +52 -0
- package/src/core/mcp/client.ts +158 -0
- package/src/core/mcp/config.ts +154 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +68 -0
- package/src/core/mcp/manager.ts +181 -0
- package/src/core/mcp/tool-bridge.ts +148 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +220 -0
- package/src/core/messages.ts +189 -0
- package/src/core/model-registry.ts +317 -0
- package/src/core/model-resolver.ts +393 -0
- package/src/core/plugins/doctor.ts +59 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +338 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +32 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +760 -0
- package/src/core/session-manager.ts +1128 -0
- package/src/core/settings-manager.ts +443 -0
- package/src/core/skills.ts +437 -0
- package/src/core/slash-commands.ts +248 -0
- package/src/core/system-prompt.ts +439 -0
- package/src/core/timings.ts +25 -0
- package/src/core/tools/ask.ts +211 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +250 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +475 -0
- package/src/core/tools/edit.ts +208 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +64 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/logger.ts +56 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +196 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +337 -0
- package/src/core/tools/exa/types.ts +168 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +261 -0
- package/src/core/tools/grep.ts +555 -0
- package/src/core/tools/index.ts +202 -0
- package/src/core/tools/ls.ts +140 -0
- package/src/core/tools/lsp/client.ts +605 -0
- package/src/core/tools/lsp/config.ts +147 -0
- package/src/core/tools/lsp/edits.ts +101 -0
- package/src/core/tools/lsp/index.ts +804 -0
- package/src/core/tools/lsp/render.ts +447 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +463 -0
- package/src/core/tools/lsp/utils.ts +486 -0
- package/src/core/tools/notebook.ts +229 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +240 -0
- package/src/core/tools/renderers.ts +540 -0
- package/src/core/tools/task/agents.ts +153 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/bundled-agents/browser.md +71 -0
- package/src/core/tools/task/bundled-agents/explore.md +82 -0
- package/src/core/tools/task/bundled-agents/plan.md +54 -0
- package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
- package/src/core/tools/task/bundled-agents/task.md +53 -0
- package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
- package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
- package/src/core/tools/task/bundled-commands/implement.md +11 -0
- package/src/core/tools/task/commands.ts +213 -0
- package/src/core/tools/task/discovery.ts +208 -0
- package/src/core/tools/task/executor.ts +367 -0
- package/src/core/tools/task/index.ts +388 -0
- package/src/core/tools/task/model-resolver.ts +115 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +232 -0
- package/src/core/tools/task/types.ts +99 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2370 -0
- package/src/core/tools/web-search/auth.ts +193 -0
- package/src/core/tools/web-search/index.ts +537 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +302 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +182 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +99 -0
- package/src/index.ts +176 -0
- package/src/main.ts +464 -0
- package/src/migrations.ts +135 -0
- package/src/modes/index.ts +43 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +196 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/footer.ts +381 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +247 -0
- package/src/modes/interactive/components/oauth-selector.ts +120 -0
- package/src/modes/interactive/components/plugin-settings.ts +479 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +204 -0
- package/src/modes/interactive/components/settings-selector.ts +453 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +675 -0
- package/src/modes/interactive/components/tree-selector.ts +866 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +183 -0
- package/src/modes/interactive/interactive-mode.ts +2516 -0
- package/src/modes/interactive/theme/dark.json +101 -0
- package/src/modes/interactive/theme/light.json +98 -0
- package/src/modes/interactive/theme/theme-schema.json +308 -0
- package/src/modes/interactive/theme/theme.ts +998 -0
- package/src/modes/print-mode.ts +128 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +483 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell.ts +276 -0
- package/src/utils/tools-manager.ts +274 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for API keys and OAuth tokens.
|
|
3
|
+
* Handles loading, saving, and refreshing credentials from auth.json.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
getEnvApiKey,
|
|
10
|
+
getOAuthApiKey,
|
|
11
|
+
loginAnthropic,
|
|
12
|
+
loginAntigravity,
|
|
13
|
+
loginGeminiCli,
|
|
14
|
+
loginGitHubCopilot,
|
|
15
|
+
type OAuthCredentials,
|
|
16
|
+
type OAuthProvider,
|
|
17
|
+
} from "@oh-my-pi/pi-ai";
|
|
18
|
+
|
|
19
|
+
export type ApiKeyCredential = {
|
|
20
|
+
type: "api_key";
|
|
21
|
+
key: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type OAuthCredential = {
|
|
25
|
+
type: "oauth";
|
|
26
|
+
} & OAuthCredentials;
|
|
27
|
+
|
|
28
|
+
export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|
29
|
+
|
|
30
|
+
export type AuthStorageData = Record<string, AuthCredential>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Credential storage backed by a JSON file.
|
|
34
|
+
*/
|
|
35
|
+
export class AuthStorage {
|
|
36
|
+
private data: AuthStorageData = {};
|
|
37
|
+
private runtimeOverrides: Map<string, string> = new Map();
|
|
38
|
+
private fallbackResolver?: (provider: string) => string | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(private authPath: string) {
|
|
41
|
+
this.reload();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Set a runtime API key override (not persisted to disk).
|
|
46
|
+
* Used for CLI --api-key flag.
|
|
47
|
+
*/
|
|
48
|
+
setRuntimeApiKey(provider: string, apiKey: string): void {
|
|
49
|
+
this.runtimeOverrides.set(provider, apiKey);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a runtime API key override.
|
|
54
|
+
*/
|
|
55
|
+
removeRuntimeApiKey(provider: string): void {
|
|
56
|
+
this.runtimeOverrides.delete(provider);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set a fallback resolver for API keys not found in auth.json or env vars.
|
|
61
|
+
* Used for custom provider keys from models.json.
|
|
62
|
+
*/
|
|
63
|
+
setFallbackResolver(resolver: (provider: string) => string | undefined): void {
|
|
64
|
+
this.fallbackResolver = resolver;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reload credentials from disk.
|
|
69
|
+
*/
|
|
70
|
+
reload(): void {
|
|
71
|
+
if (!existsSync(this.authPath)) {
|
|
72
|
+
this.data = {};
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const file = Bun.file(this.authPath);
|
|
77
|
+
this.data = JSON.parse(file.text() as unknown as string);
|
|
78
|
+
} catch {
|
|
79
|
+
this.data = {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save credentials to disk.
|
|
85
|
+
*/
|
|
86
|
+
private save(): void {
|
|
87
|
+
const dir = dirname(this.authPath);
|
|
88
|
+
if (!existsSync(dir)) {
|
|
89
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
}
|
|
91
|
+
Bun.write(this.authPath, JSON.stringify(this.data, null, 2));
|
|
92
|
+
chmodSync(this.authPath, 0o600);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get credential for a provider.
|
|
97
|
+
*/
|
|
98
|
+
get(provider: string): AuthCredential | undefined {
|
|
99
|
+
return this.data[provider] ?? undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set credential for a provider.
|
|
104
|
+
*/
|
|
105
|
+
set(provider: string, credential: AuthCredential): void {
|
|
106
|
+
this.data[provider] = credential;
|
|
107
|
+
this.save();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove credential for a provider.
|
|
112
|
+
*/
|
|
113
|
+
remove(provider: string): void {
|
|
114
|
+
delete this.data[provider];
|
|
115
|
+
this.save();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all providers with credentials.
|
|
120
|
+
*/
|
|
121
|
+
list(): string[] {
|
|
122
|
+
return Object.keys(this.data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if credentials exist for a provider.
|
|
127
|
+
*/
|
|
128
|
+
has(provider: string): boolean {
|
|
129
|
+
return provider in this.data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get all credentials (for passing to getOAuthApiKey).
|
|
134
|
+
*/
|
|
135
|
+
getAll(): AuthStorageData {
|
|
136
|
+
return { ...this.data };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Login to an OAuth provider.
|
|
141
|
+
*/
|
|
142
|
+
async login(
|
|
143
|
+
provider: OAuthProvider,
|
|
144
|
+
callbacks: {
|
|
145
|
+
onAuth: (info: { url: string; instructions?: string }) => void;
|
|
146
|
+
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
|
147
|
+
onProgress?: (message: string) => void;
|
|
148
|
+
},
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
let credentials: OAuthCredentials;
|
|
151
|
+
|
|
152
|
+
switch (provider) {
|
|
153
|
+
case "anthropic":
|
|
154
|
+
credentials = await loginAnthropic(
|
|
155
|
+
(url) => callbacks.onAuth({ url }),
|
|
156
|
+
() => callbacks.onPrompt({ message: "Paste the authorization code:" }),
|
|
157
|
+
);
|
|
158
|
+
break;
|
|
159
|
+
case "github-copilot":
|
|
160
|
+
credentials = await loginGitHubCopilot({
|
|
161
|
+
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
|
|
162
|
+
onPrompt: callbacks.onPrompt,
|
|
163
|
+
onProgress: callbacks.onProgress,
|
|
164
|
+
});
|
|
165
|
+
break;
|
|
166
|
+
case "google-gemini-cli":
|
|
167
|
+
credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
|
|
168
|
+
break;
|
|
169
|
+
case "google-antigravity":
|
|
170
|
+
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.set(provider, { type: "oauth", ...credentials });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Logout from a provider.
|
|
181
|
+
*/
|
|
182
|
+
logout(provider: string): void {
|
|
183
|
+
this.remove(provider);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get API key for a provider.
|
|
188
|
+
* Priority:
|
|
189
|
+
* 1. Runtime override (CLI --api-key)
|
|
190
|
+
* 2. API key from auth.json
|
|
191
|
+
* 3. OAuth token from auth.json (auto-refreshed)
|
|
192
|
+
* 4. Environment variable
|
|
193
|
+
* 5. Fallback resolver (models.json custom providers)
|
|
194
|
+
*/
|
|
195
|
+
async getApiKey(provider: string): Promise<string | undefined> {
|
|
196
|
+
// Runtime override takes highest priority
|
|
197
|
+
const runtimeKey = this.runtimeOverrides.get(provider);
|
|
198
|
+
if (runtimeKey) {
|
|
199
|
+
return runtimeKey;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const cred = this.data[provider];
|
|
203
|
+
|
|
204
|
+
if (cred?.type === "api_key") {
|
|
205
|
+
return cred.key;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (cred?.type === "oauth") {
|
|
209
|
+
// Filter to only oauth credentials for getOAuthApiKey
|
|
210
|
+
const oauthCreds: Record<string, OAuthCredentials> = {};
|
|
211
|
+
for (const [key, value] of Object.entries(this.data)) {
|
|
212
|
+
if (value.type === "oauth") {
|
|
213
|
+
oauthCreds[key] = value;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
|
|
219
|
+
if (result) {
|
|
220
|
+
this.data[provider] = { type: "oauth", ...result.newCredentials };
|
|
221
|
+
this.save();
|
|
222
|
+
return result.apiKey;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
this.remove(provider);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fall back to environment variable
|
|
230
|
+
const envKey = getEnvApiKey(provider);
|
|
231
|
+
if (envKey) return envKey;
|
|
232
|
+
|
|
233
|
+
// Fall back to custom resolver (e.g., models.json custom providers)
|
|
234
|
+
return this.fallbackResolver?.(provider) ?? undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash command execution with streaming support and cancellation.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a unified bash execution implementation used by:
|
|
5
|
+
* - AgentSession.executeBash() for interactive and RPC modes
|
|
6
|
+
* - Direct calls from modes that need bash execution
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createWriteStream, type WriteStream } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { Subprocess } from "bun";
|
|
13
|
+
import stripAnsi from "strip-ansi";
|
|
14
|
+
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
|
15
|
+
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface BashExecutorOptions {
|
|
22
|
+
/** Callback for streaming output chunks (already sanitized) */
|
|
23
|
+
onChunk?: (chunk: string) => void;
|
|
24
|
+
/** AbortSignal for cancellation */
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BashResult {
|
|
29
|
+
/** Combined stdout + stderr output (sanitized, possibly truncated) */
|
|
30
|
+
output: string;
|
|
31
|
+
/** Process exit code (undefined if killed/cancelled) */
|
|
32
|
+
exitCode: number | undefined;
|
|
33
|
+
/** Whether the command was cancelled via signal */
|
|
34
|
+
cancelled: boolean;
|
|
35
|
+
/** Whether the output was truncated */
|
|
36
|
+
truncated: boolean;
|
|
37
|
+
/** Path to temp file containing full output (if output exceeded truncation threshold) */
|
|
38
|
+
fullOutputPath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Implementation
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Execute a bash command with optional streaming and cancellation support.
|
|
47
|
+
*
|
|
48
|
+
* Features:
|
|
49
|
+
* - Streams sanitized output via onChunk callback
|
|
50
|
+
* - Writes large output to temp file for later retrieval
|
|
51
|
+
* - Supports cancellation via AbortSignal
|
|
52
|
+
* - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines)
|
|
53
|
+
* - Truncates output if it exceeds the default max bytes
|
|
54
|
+
*
|
|
55
|
+
* @param command - The bash command to execute
|
|
56
|
+
* @param options - Optional streaming callback and abort signal
|
|
57
|
+
* @returns Promise resolving to execution result
|
|
58
|
+
*/
|
|
59
|
+
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const { shell, args } = getShellConfig();
|
|
62
|
+
const child: Subprocess = Bun.spawn([shell, ...args, command], {
|
|
63
|
+
stdin: "ignore",
|
|
64
|
+
stdout: "pipe",
|
|
65
|
+
stderr: "pipe",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Track sanitized output for truncation
|
|
69
|
+
const outputChunks: string[] = [];
|
|
70
|
+
let outputBytes = 0;
|
|
71
|
+
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
|
72
|
+
|
|
73
|
+
// Temp file for large output
|
|
74
|
+
let tempFilePath: string | undefined;
|
|
75
|
+
let tempFileStream: WriteStream | undefined;
|
|
76
|
+
let totalBytes = 0;
|
|
77
|
+
|
|
78
|
+
// Handle abort signal
|
|
79
|
+
const abortHandler = () => {
|
|
80
|
+
killProcessTree(child.pid);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (options?.signal) {
|
|
84
|
+
if (options.signal.aborted) {
|
|
85
|
+
// Already aborted, don't even start
|
|
86
|
+
child.kill();
|
|
87
|
+
resolve({
|
|
88
|
+
output: "",
|
|
89
|
+
exitCode: undefined,
|
|
90
|
+
cancelled: true,
|
|
91
|
+
truncated: false,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const handleData = (data: Buffer) => {
|
|
99
|
+
totalBytes += data.length;
|
|
100
|
+
|
|
101
|
+
// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
|
|
102
|
+
const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");
|
|
103
|
+
|
|
104
|
+
// Start writing to temp file if exceeds threshold
|
|
105
|
+
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
|
106
|
+
const randomId = crypto.getRandomValues(new Uint8Array(8));
|
|
107
|
+
const id = Array.from(randomId, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
108
|
+
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
|
109
|
+
tempFileStream = createWriteStream(tempFilePath);
|
|
110
|
+
// Write already-buffered chunks to temp file
|
|
111
|
+
for (const chunk of outputChunks) {
|
|
112
|
+
tempFileStream.write(chunk);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (tempFileStream) {
|
|
117
|
+
tempFileStream.write(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Keep rolling buffer of sanitized text
|
|
121
|
+
outputChunks.push(text);
|
|
122
|
+
outputBytes += text.length;
|
|
123
|
+
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
|
124
|
+
const removed = outputChunks.shift()!;
|
|
125
|
+
outputBytes -= removed.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Stream to callback if provided
|
|
129
|
+
if (options?.onChunk) {
|
|
130
|
+
options.onChunk(text);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Read streams asynchronously
|
|
135
|
+
(async () => {
|
|
136
|
+
try {
|
|
137
|
+
const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
|
|
138
|
+
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
|
|
139
|
+
|
|
140
|
+
await Promise.all([
|
|
141
|
+
(async () => {
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await stdoutReader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
handleData(Buffer.from(value));
|
|
146
|
+
}
|
|
147
|
+
})(),
|
|
148
|
+
(async () => {
|
|
149
|
+
while (true) {
|
|
150
|
+
const { done, value } = await stderrReader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
handleData(Buffer.from(value));
|
|
153
|
+
}
|
|
154
|
+
})(),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const exitCode = await child.exited;
|
|
158
|
+
|
|
159
|
+
// Clean up abort listener
|
|
160
|
+
if (options?.signal) {
|
|
161
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (tempFileStream) {
|
|
165
|
+
tempFileStream.end();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Combine buffered chunks for truncation (already sanitized)
|
|
169
|
+
const fullOutput = outputChunks.join("");
|
|
170
|
+
const truncationResult = truncateTail(fullOutput);
|
|
171
|
+
|
|
172
|
+
// Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
|
|
173
|
+
const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
|
|
174
|
+
|
|
175
|
+
resolve({
|
|
176
|
+
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
|
177
|
+
exitCode: cancelled ? undefined : exitCode,
|
|
178
|
+
cancelled,
|
|
179
|
+
truncated: truncationResult.truncated,
|
|
180
|
+
fullOutputPath: tempFilePath,
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// Clean up abort listener
|
|
184
|
+
if (options?.signal) {
|
|
185
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (tempFileStream) {
|
|
189
|
+
tempFileStream.end();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
reject(err);
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
});
|
|
196
|
+
}
|