@oh-my-pi/pi-coding-agent 12.12.3 → 12.14.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 +92 -0
- package/package.json +8 -7
- package/scripts/generate-docs-index.ts +56 -0
- package/src/cli/ssh-cli.ts +179 -0
- package/src/cli.ts +1 -0
- package/src/commands/ssh.ts +60 -0
- package/src/commit/prompts/analysis-system.md +1 -3
- package/src/config/prompt-templates.ts +20 -5
- package/src/config/settings-schema.ts +10 -1
- package/src/discovery/builtin.ts +14 -4
- package/src/discovery/ssh.ts +26 -19
- package/src/extensibility/extensions/types.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +101 -0
- package/src/internal-urls/docs-protocol.ts +84 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/modes/controllers/event-controller.ts +20 -0
- package/src/modes/controllers/ssh-command-controller.ts +452 -0
- package/src/modes/interactive-mode.ts +6 -0
- package/src/modes/types.ts +1 -0
- package/src/patch/diff.ts +1 -1
- package/src/patch/hashline.ts +274 -303
- package/src/patch/index.ts +324 -103
- package/src/patch/shared.ts +25 -28
- package/src/prompts/system/system-prompt.md +14 -2
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/grep.md +12 -8
- package/src/prompts/tools/hashline.md +207 -60
- package/src/prompts/tools/read.md +3 -3
- package/src/sdk.ts +17 -0
- package/src/session/agent-session.ts +1 -0
- package/src/session/auth-storage.ts +6 -0
- package/src/slash-commands/builtin-registry.ts +20 -0
- package/src/ssh/config-writer.ts +183 -0
- package/src/tools/bash-interactive.ts +47 -7
- package/src/tools/fetch.ts +4 -3
- package/src/tools/grep.ts +14 -4
- package/src/tools/read.ts +2 -2
- package/src/tools/ssh.ts +1 -1
- package/src/web/search/render.ts +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Configuration File Writer
|
|
3
|
+
*
|
|
4
|
+
* Utilities for reading/writing ssh.json files at user or project level.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
9
|
+
|
|
10
|
+
export interface SSHHostConfig {
|
|
11
|
+
host: string;
|
|
12
|
+
username?: string;
|
|
13
|
+
port?: number;
|
|
14
|
+
keyPath?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
compat?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SSHConfigFile {
|
|
20
|
+
hosts?: Record<string, SSHHostConfig>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read an SSH config file.
|
|
25
|
+
* Returns empty config if file doesn't exist.
|
|
26
|
+
*/
|
|
27
|
+
export async function readSSHConfigFile(filePath: string): Promise<SSHConfigFile> {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
30
|
+
const parsed = JSON.parse(content) as SSHConfigFile;
|
|
31
|
+
return parsed;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (isEnoent(error)) {
|
|
34
|
+
// File doesn't exist, return empty config
|
|
35
|
+
return { hosts: {} };
|
|
36
|
+
}
|
|
37
|
+
if (error instanceof SyntaxError) {
|
|
38
|
+
throw new Error(`Failed to parse SSH config file ${filePath}: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write an SSH config file atomically.
|
|
46
|
+
* Creates parent directories if they don't exist.
|
|
47
|
+
*/
|
|
48
|
+
export async function writeSSHConfigFile(filePath: string, config: SSHConfigFile): Promise<void> {
|
|
49
|
+
// Ensure parent directory exists
|
|
50
|
+
const dir = path.dirname(filePath);
|
|
51
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
|
|
53
|
+
// Write to temp file first (atomic write)
|
|
54
|
+
const tmpPath = `${filePath}.tmp`;
|
|
55
|
+
const content = JSON.stringify(config, null, 2);
|
|
56
|
+
await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
57
|
+
|
|
58
|
+
// Rename to final path (atomic on most systems)
|
|
59
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate host name.
|
|
64
|
+
* @returns Error message if invalid, undefined if valid
|
|
65
|
+
*/
|
|
66
|
+
export function validateHostName(name: string): string | undefined {
|
|
67
|
+
if (!name) {
|
|
68
|
+
return "Host name cannot be empty";
|
|
69
|
+
}
|
|
70
|
+
if (name.length > 100) {
|
|
71
|
+
return "Host name is too long (max 100 characters)";
|
|
72
|
+
}
|
|
73
|
+
// Check for invalid characters (only allow alphanumeric, dash, underscore, dot)
|
|
74
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
|
75
|
+
return "Host name can only contain letters, numbers, dash, underscore, and dot";
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add an SSH host to a config file.
|
|
82
|
+
*
|
|
83
|
+
* @throws Error if host name already exists or validation fails
|
|
84
|
+
*/
|
|
85
|
+
export async function addSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise<void> {
|
|
86
|
+
// Validate host name
|
|
87
|
+
const nameError = validateHostName(name);
|
|
88
|
+
if (nameError) {
|
|
89
|
+
throw new Error(nameError);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate host field
|
|
93
|
+
if (!hostConfig.host) {
|
|
94
|
+
throw new Error("Host address cannot be empty");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read existing config
|
|
98
|
+
const existing = await readSSHConfigFile(filePath);
|
|
99
|
+
|
|
100
|
+
// Check for duplicate name
|
|
101
|
+
if (existing.hosts?.[name]) {
|
|
102
|
+
throw new Error(`Host "${name}" already exists in ${filePath}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add host
|
|
106
|
+
const updated: SSHConfigFile = {
|
|
107
|
+
...existing,
|
|
108
|
+
hosts: {
|
|
109
|
+
...existing.hosts,
|
|
110
|
+
[name]: hostConfig,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Write back
|
|
115
|
+
await writeSSHConfigFile(filePath, updated);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Update an existing SSH host in a config file.
|
|
120
|
+
* If the host doesn't exist, this will add it.
|
|
121
|
+
*
|
|
122
|
+
* @throws Error if validation fails
|
|
123
|
+
*/
|
|
124
|
+
export async function updateSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise<void> {
|
|
125
|
+
// Validate host name
|
|
126
|
+
const nameError = validateHostName(name);
|
|
127
|
+
if (nameError) {
|
|
128
|
+
throw new Error(nameError);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate host field
|
|
132
|
+
if (!hostConfig.host) {
|
|
133
|
+
throw new Error("Host address cannot be empty");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Read existing config
|
|
137
|
+
const existing = await readSSHConfigFile(filePath);
|
|
138
|
+
|
|
139
|
+
// Update host
|
|
140
|
+
const updated: SSHConfigFile = {
|
|
141
|
+
...existing,
|
|
142
|
+
hosts: {
|
|
143
|
+
...existing.hosts,
|
|
144
|
+
[name]: hostConfig,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Write back
|
|
149
|
+
await writeSSHConfigFile(filePath, updated);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Remove an SSH host from a config file.
|
|
154
|
+
*
|
|
155
|
+
* @throws Error if host doesn't exist
|
|
156
|
+
*/
|
|
157
|
+
export async function removeSSHHost(filePath: string, name: string): Promise<void> {
|
|
158
|
+
// Read existing config
|
|
159
|
+
const existing = await readSSHConfigFile(filePath);
|
|
160
|
+
|
|
161
|
+
// Check if host exists
|
|
162
|
+
if (!existing.hosts?.[name]) {
|
|
163
|
+
throw new Error(`Host "${name}" not found in ${filePath}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Remove host
|
|
167
|
+
const { [name]: _removed, ...remaining } = existing.hosts;
|
|
168
|
+
const updated: SSHConfigFile = {
|
|
169
|
+
...existing,
|
|
170
|
+
hosts: remaining,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Write back
|
|
174
|
+
await writeSSHConfigFile(filePath, updated);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* List all host names in a config file.
|
|
179
|
+
*/
|
|
180
|
+
export async function listSSHHosts(filePath: string): Promise<string[]> {
|
|
181
|
+
const config = await readSSHConfigFile(filePath);
|
|
182
|
+
return Object.keys(config.hosts ?? {});
|
|
183
|
+
}
|
|
@@ -95,6 +95,10 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
95
95
|
#session: PtySession | null = null;
|
|
96
96
|
#lastCols = 0;
|
|
97
97
|
#lastRows = 0;
|
|
98
|
+
#writeQueue: string[] = [];
|
|
99
|
+
#writeOffset = 0;
|
|
100
|
+
#flushResolvers: Array<() => void> = [];
|
|
101
|
+
#writing = false;
|
|
98
102
|
|
|
99
103
|
constructor(
|
|
100
104
|
private readonly command: string,
|
|
@@ -117,7 +121,47 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
appendOutput(chunk: string): void {
|
|
120
|
-
this.#
|
|
124
|
+
this.#writeQueue.push(chunk);
|
|
125
|
+
this.#drainQueue();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#drainQueue(): void {
|
|
129
|
+
if (this.#writing) return;
|
|
130
|
+
if (this.#writeOffset >= this.#writeQueue.length) {
|
|
131
|
+
this.#resolveFlushWaiters();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.#writing = true;
|
|
135
|
+
const data = this.#writeQueue[this.#writeOffset]!;
|
|
136
|
+
this.#terminal.write(data, () => {
|
|
137
|
+
this.#writing = false;
|
|
138
|
+
this.#writeOffset += 1;
|
|
139
|
+
if (this.#writeOffset >= this.#writeQueue.length) {
|
|
140
|
+
this.#writeQueue = [];
|
|
141
|
+
this.#writeOffset = 0;
|
|
142
|
+
this.#resolveFlushWaiters();
|
|
143
|
+
}
|
|
144
|
+
this.#drainQueue();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#resolveFlushWaiters(): void {
|
|
149
|
+
if (this.#writing || this.#writeOffset < this.#writeQueue.length) return;
|
|
150
|
+
if (this.#flushResolvers.length === 0) return;
|
|
151
|
+
const resolvers = this.#flushResolvers;
|
|
152
|
+
this.#flushResolvers = [];
|
|
153
|
+
for (const resolve of resolvers) {
|
|
154
|
+
resolve();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
flushOutput(): Promise<void> {
|
|
159
|
+
if (!this.#writing && this.#writeOffset >= this.#writeQueue.length) {
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
}
|
|
162
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
163
|
+
this.#flushResolvers.push(resolve);
|
|
164
|
+
return promise;
|
|
121
165
|
}
|
|
122
166
|
|
|
123
167
|
setSession(session: PtySession): void {
|
|
@@ -258,6 +302,7 @@ export async function runInteractiveBashPty(
|
|
|
258
302
|
component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
|
|
259
303
|
tui.requestRender();
|
|
260
304
|
void (async () => {
|
|
305
|
+
await component.flushOutput();
|
|
261
306
|
await pendingChunks;
|
|
262
307
|
const summary = await sink.dump();
|
|
263
308
|
done({
|
|
@@ -349,12 +394,7 @@ export async function runInteractiveBashPty(
|
|
|
349
394
|
},
|
|
350
395
|
(err, chunk) => {
|
|
351
396
|
if (err || !chunk) return;
|
|
352
|
-
|
|
353
|
-
component.appendOutput(chunk);
|
|
354
|
-
} catch {
|
|
355
|
-
const normalizedChunk = normalizeCaptureChunk(chunk);
|
|
356
|
-
component.appendOutput(normalizedChunk);
|
|
357
|
-
}
|
|
397
|
+
component.appendOutput(chunk);
|
|
358
398
|
const normalizedChunk = normalizeCaptureChunk(chunk);
|
|
359
399
|
pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
|
|
360
400
|
tui.requestRender();
|
package/src/tools/fetch.ts
CHANGED
|
@@ -966,11 +966,12 @@ function countNonEmptyLines(text: string): number {
|
|
|
966
966
|
|
|
967
967
|
/** Render fetch call (URL preview) */
|
|
968
968
|
export function renderFetchCall(
|
|
969
|
-
args: { url
|
|
969
|
+
args: { url?: string; timeout?: number; raw?: boolean },
|
|
970
970
|
uiTheme: Theme = theme,
|
|
971
971
|
): Component {
|
|
972
|
-
const
|
|
973
|
-
const
|
|
972
|
+
const url = args.url ?? "";
|
|
973
|
+
const domain = getDomain(url);
|
|
974
|
+
const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "\u2026");
|
|
974
975
|
const description = `${domain}${path ? ` ${path}` : ""}`.trim();
|
|
975
976
|
const meta: string[] = [];
|
|
976
977
|
if (args.raw) meta.push("raw");
|
package/src/tools/grep.ts
CHANGED
|
@@ -104,7 +104,17 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
104
104
|
const effectiveMultiline = multiline ?? patternHasNewline;
|
|
105
105
|
|
|
106
106
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
107
|
-
|
|
107
|
+
let searchPath: string;
|
|
108
|
+
const internalRouter = this.session.internalRouter;
|
|
109
|
+
if (searchDir && internalRouter?.canHandle(searchDir)) {
|
|
110
|
+
const resource = await internalRouter.resolve(searchDir);
|
|
111
|
+
if (!resource.sourcePath) {
|
|
112
|
+
throw new ToolError(`Cannot grep internal URL without a backing file: ${searchDir}`);
|
|
113
|
+
}
|
|
114
|
+
searchPath = resource.sourcePath;
|
|
115
|
+
} else {
|
|
116
|
+
searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
|
|
117
|
+
}
|
|
108
118
|
const scopePath = (() => {
|
|
109
119
|
const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
|
|
110
120
|
return relative.length === 0 ? "." : relative;
|
|
@@ -209,11 +219,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
209
219
|
|
|
210
220
|
const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
|
|
211
221
|
if (useHashLines) {
|
|
212
|
-
const ref = `${lineNumber}
|
|
213
|
-
return isMatch ? `>>${ref}
|
|
222
|
+
const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
|
|
223
|
+
return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`;
|
|
214
224
|
}
|
|
215
225
|
const padded = lineNumber.toString().padStart(lineWidth, " ");
|
|
216
|
-
return isMatch ? `>>${padded}
|
|
226
|
+
return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`;
|
|
217
227
|
};
|
|
218
228
|
|
|
219
229
|
// Add context before
|
package/src/tools/read.ts
CHANGED
|
@@ -772,7 +772,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
772
772
|
const prependHashLines = (text: string, startNum: number): string => {
|
|
773
773
|
const textLines = text.split("\n");
|
|
774
774
|
return textLines
|
|
775
|
-
.map((line, i) => `${startNum + i}
|
|
775
|
+
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`)
|
|
776
776
|
.join("\n");
|
|
777
777
|
};
|
|
778
778
|
const formatText = (text: string, startNum: number): string => {
|
|
@@ -929,7 +929,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
929
929
|
};
|
|
930
930
|
const prependHashLines = (text: string, startNum: number): string => {
|
|
931
931
|
const textLines = text.split("\n");
|
|
932
|
-
return textLines.map((line, i) => `${startNum + i}
|
|
932
|
+
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`).join("\n");
|
|
933
933
|
};
|
|
934
934
|
const formatText = (text: string, startNum: number): string => {
|
|
935
935
|
if (shouldAddHashLines) return prependHashLines(text, startNum);
|
package/src/tools/ssh.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { toolResult } from "./tool-result";
|
|
|
23
23
|
import { DEFAULT_MAX_BYTES } from "./truncate";
|
|
24
24
|
|
|
25
25
|
const sshSchema = Type.Object({
|
|
26
|
-
host: Type.String({ description: "Host name from
|
|
26
|
+
host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }),
|
|
27
27
|
command: Type.String({ description: "Command to execute on the remote host" }),
|
|
28
28
|
cwd: Type.Optional(Type.String({ description: "Remote working directory (optional)" })),
|
|
29
29
|
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 60)" })),
|
package/src/web/search/render.ts
CHANGED
|
@@ -282,11 +282,11 @@ export function renderSearchResult(
|
|
|
282
282
|
|
|
283
283
|
/** Render web search call (query preview) */
|
|
284
284
|
export function renderSearchCall(
|
|
285
|
-
args: { query
|
|
285
|
+
args: { query?: string; provider?: string; [key: string]: unknown },
|
|
286
286
|
theme: Theme,
|
|
287
287
|
): Component {
|
|
288
288
|
const provider = args.provider ?? "auto";
|
|
289
|
-
const query = truncateToWidth(args.query, 80);
|
|
289
|
+
const query = truncateToWidth(args.query ?? "", 80);
|
|
290
290
|
const text = renderStatusLine({ icon: "pending", title: "Web Search", description: query, meta: [provider] }, theme);
|
|
291
291
|
return new Text(text, 0, 0);
|
|
292
292
|
}
|