@meowlynxsea/koi 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.
Files changed (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * BashTool — Shell execution with safety controls
3
+ *
4
+ * Features:
5
+ * - Timeout with SIGTERM → SIGKILL escalation
6
+ * - Output size limit (capped to prevent context overflow)
7
+ * - Dangerous command warnings (displayed but not blocking)
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import { spawn } from "child_process";
12
+ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
13
+ import type { TextContent } from "@mariozechner/pi-ai";
14
+ import { checkPermission, isDangerousBashCommand } from "../agent/check-permissions.js";
15
+ import { requestPermission } from "../agent/permission-ui.js";
16
+ import { withWriteLock } from "../agent/tool-orchestration.js";
17
+ import type { ToolResultWithError } from "./types.js";
18
+
19
+ export const bashSchema = Type.Object({
20
+ command: Type.String({ description: "Bash command to execute" }),
21
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 60). For long-running tasks that exceed this limit, use CreateMonitor instead.", default: 60 })),
22
+ });
23
+
24
+ export type BashToolInput = {
25
+ command: string;
26
+ timeout?: number;
27
+ };
28
+
29
+ const MAX_OUTPUT_CHARS = 200_000;
30
+
31
+ function execBash(command: string, timeoutSec?: number): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
32
+ return new Promise((resolve, reject) => {
33
+ const shell = process.platform === "win32" ? "cmd" : "bash";
34
+ const shellFlag = process.platform === "win32" ? "/c" : "-c";
35
+ const child = spawn(shell, [shellFlag, command], {
36
+ cwd: process.cwd(),
37
+ env: { ...process.env, CLAUDECODE: "1", GIT_EDITOR: "true" },
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ });
40
+
41
+ let stdout = "";
42
+ let stderr = "";
43
+ let timedOut = false;
44
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
45
+
46
+ const effectiveTimeout = timeoutSec ?? 60;
47
+ if (effectiveTimeout > 0) {
48
+ timeoutHandle = setTimeout(() => {
49
+ timedOut = true;
50
+ child.kill("SIGTERM");
51
+ setTimeout(() => {
52
+ if (!child.killed) child.kill("SIGKILL");
53
+ }, 5000);
54
+ }, effectiveTimeout * 1000);
55
+ }
56
+
57
+ child.stdout?.on("data", (chunk: Buffer) => {
58
+ stdout += chunk.toString("utf-8");
59
+ if (stdout.length + stderr.length > MAX_OUTPUT_CHARS * 2) {
60
+ child.kill("SIGKILL");
61
+ }
62
+ });
63
+ child.stderr?.on("data", (chunk: Buffer) => {
64
+ stderr += chunk.toString("utf-8");
65
+ if (stdout.length + stderr.length > MAX_OUTPUT_CHARS * 2) {
66
+ child.kill("SIGKILL");
67
+ }
68
+ });
69
+
70
+ child.on("error", (err) => {
71
+ if (timeoutHandle) clearTimeout(timeoutHandle);
72
+ reject(err);
73
+ });
74
+
75
+ child.on("close", (code) => {
76
+ if (timeoutHandle) clearTimeout(timeoutHandle);
77
+ resolve({ stdout, stderr, exitCode: code ?? 0, timedOut });
78
+ });
79
+ });
80
+ }
81
+
82
+ export async function executeBash(params: BashToolInput): Promise<{ content: TextContent[]; details: { exitCode: number; timedOut: boolean } }> {
83
+ const { stdout, stderr, exitCode, timedOut } = await execBash(params.command, params.timeout);
84
+
85
+ let output = stdout;
86
+ if (stderr) {
87
+ output += (output ? "\n\n" : "") + `[stderr]\n${stderr}`;
88
+ }
89
+ if (timedOut) {
90
+ output += "\n\n[Command timed out and was terminated]";
91
+ }
92
+ if (output.length > MAX_OUTPUT_CHARS) {
93
+ output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[Output truncated: ${output.length} chars total, limit: ${MAX_OUTPUT_CHARS}]`;
94
+ }
95
+
96
+ const warning = isDangerousBashCommand(params.command)
97
+ ? "\n\n[Warning: This command may be destructive. Proceed with caution.]"
98
+ : "";
99
+
100
+ return {
101
+ content: [{ type: "text", text: output + warning }],
102
+ details: { exitCode, timedOut },
103
+ };
104
+ }
105
+
106
+ export function createBashToolDefinition(_cwd: string): ToolDefinition<typeof bashSchema, { exitCode: number; timedOut: boolean }> {
107
+ return {
108
+ name: "bash",
109
+ label: "Bash",
110
+ description:
111
+ "Execute a bash command in the environment.\n\n" +
112
+ "IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands. " +
113
+ "Instead, use the appropriate dedicated tool.\n\n" +
114
+ "Git Safety Protocol:\n" +
115
+ "- NEVER update the git config\n" +
116
+ "- NEVER run destructive git commands (push --force, reset --hard, checkout .) unless explicitly requested\n" +
117
+ "- NEVER use git commands with the -i flag (interactive)\n" +
118
+ "- NEVER skip hooks (--no-verify, --no-gpg-sign) unless explicitly asked",
119
+ promptSnippet: "Bash: execute shell commands (last resort — prefer dedicated tools)",
120
+ parameters: bashSchema,
121
+ executionMode: "parallel",
122
+ async execute(_toolCallId, params, _signal, _onUpdate) {
123
+ return withWriteLock(async () => {
124
+ const perm = checkPermission("bash", params);
125
+ if (perm.decision === "deny") {
126
+ const result: ToolResultWithError<{ exitCode: number; timedOut: boolean }> = {
127
+ content: [{ type: "text", text: `Permission denied: ${perm.reason ?? "bash operation blocked"}` }],
128
+ details: { exitCode: 1, timedOut: false },
129
+ isError: true,
130
+ };
131
+ return result;
132
+ }
133
+ if (perm.decision === "ask") {
134
+ const allowed = await requestPermission({ toolName: "bash", args: params, reason: perm.reason ?? "Confirm shell command" });
135
+ if (!allowed) {
136
+ const result: ToolResultWithError<{ exitCode: number; timedOut: boolean }> = {
137
+ content: [{ type: "text", text: "User denied permission to execute command." }],
138
+ details: { exitCode: 1, timedOut: false },
139
+ isError: true,
140
+ };
141
+ return result;
142
+ }
143
+ }
144
+ return await executeBash(params);
145
+ });
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * FileEditTool — Exact string replacement in files
3
+ *
4
+ * Features:
5
+ * - Exact string matching with quote normalization (smart → straight quotes)
6
+ * - Uniqueness check: old_string must be unique unless replace_all is true
7
+ * - Diff generation using the `diff` library
8
+ * - CRLF normalization and preservation on write
9
+ */
10
+
11
+ import { Type } from "typebox";
12
+ import { readFileSync, writeFileSync, existsSync } from "fs";
13
+ import { resolve } from "path";
14
+ import { structuredPatch } from "diff";
15
+ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
16
+ import type { TextContent } from "@mariozechner/pi-ai";
17
+ import { checkPermission } from "../agent/check-permissions.js";
18
+ import { requestPermission } from "../agent/permission-ui.js";
19
+ import { withWriteLock } from "../agent/tool-orchestration.js";
20
+ import type { ToolResultWithError } from "./types.js";
21
+
22
+ export const editSchema = Type.Object({
23
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
24
+ old_string: Type.String({ description: "Exact text to replace. Must be unique in the file unless replace_all is true." }),
25
+ new_string: Type.String({ description: "Replacement text" }),
26
+ replace_all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: false)" })),
27
+ });
28
+
29
+ export type EditToolInput = {
30
+ path: string;
31
+ old_string: string;
32
+ new_string: string;
33
+ replace_all?: boolean;
34
+ };
35
+
36
+ // Smart quotes → straight quotes
37
+ function normalizeQuotes(s: string): string {
38
+ return s
39
+ .replace(/[\u2018\u2019]/g, "'")
40
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
41
+ .replace(/[\u2032\u2033]/g, "'");
42
+ }
43
+
44
+ function findActualString(fileContent: string, searchString: string): string | null {
45
+ if (fileContent.includes(searchString)) {
46
+ return searchString;
47
+ }
48
+ const normalizedSearch = normalizeQuotes(searchString);
49
+ const normalizedFile = normalizeQuotes(fileContent);
50
+ const idx = normalizedFile.indexOf(normalizedSearch);
51
+ if (idx >= 0) {
52
+ // Return the original substring from file (preserves original quote style)
53
+ return fileContent.slice(idx, idx + searchString.length);
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function generateDiff(filePath: string, oldContent: string, newContent: string): string {
59
+ const patch = structuredPatch(filePath, filePath, oldContent, newContent, "", "", { context: 8 });
60
+ if (!patch || patch.hunks.length === 0) return "";
61
+ let result = `--- ${filePath}\n+++ ${filePath}\n`;
62
+ for (const hunk of patch.hunks) {
63
+ result += `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`;
64
+ for (const line of hunk.lines) {
65
+ result += line + "\n";
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ export async function executeEdit(params: EditToolInput): Promise<{ content: TextContent[]; details: { replacements: number; diff: string } }> {
72
+ const filePath = resolve(params.path);
73
+
74
+ if (!existsSync(filePath)) {
75
+ throw new Error(`File not found: ${params.path}`);
76
+ }
77
+
78
+ const buffer = readFileSync(filePath);
79
+ const encoding = buffer[0] === 0xff && buffer[1] === 0xfe ? "utf16le" : "utf8";
80
+ const originalContent = buffer.toString(encoding);
81
+ const crlf = originalContent.includes("\r\n");
82
+ const fileContent = originalContent.replace(/\r\n/g, "\n");
83
+
84
+ if (params.old_string === params.new_string) {
85
+ throw new Error("old_string and new_string are identical — no change needed.");
86
+ }
87
+
88
+ const actualOld = findActualString(fileContent, params.old_string);
89
+ if (actualOld === null) {
90
+ throw new Error(
91
+ `Could not find the string to replace in ${params.path}.\n` +
92
+ `Make sure old_string matches exactly (including indentation).`
93
+ );
94
+ }
95
+
96
+ const matches = fileContent.split(actualOld).length - 1;
97
+ if (matches > 1 && !params.replace_all) {
98
+ throw new Error(
99
+ `Found ${matches} matches of the string to replace, but replace_all is false.\n` +
100
+ `To replace all occurrences, set replace_all to true. ` +
101
+ `To replace only one occurrence, provide more context to uniquely identify the instance.`
102
+ );
103
+ }
104
+
105
+ const newContent = params.replace_all
106
+ ? fileContent.split(actualOld).join(params.new_string)
107
+ : fileContent.replace(actualOld, params.new_string);
108
+
109
+ const diff = generateDiff(params.path, fileContent, newContent);
110
+
111
+ // Restore original line endings if file had CRLF
112
+ const outputContent = crlf ? newContent.replace(/\n/g, "\r\n") : newContent;
113
+ writeFileSync(filePath, outputContent, encoding as "utf-8");
114
+
115
+ const replacementCount = params.replace_all ? matches : 1;
116
+ const diffDisplay = diff ? `\n\nDiff:\n${diff}` : "";
117
+
118
+ return {
119
+ content: [{ type: "text", text: `File edited: ${params.path}${diffDisplay}` }],
120
+ details: { replacements: replacementCount, diff },
121
+ };
122
+ }
123
+
124
+ export function createEditToolDefinition(_cwd: string): ToolDefinition<typeof editSchema, { replacements: number; diff: string }> {
125
+ return {
126
+ name: "edit",
127
+ label: "Edit",
128
+ description:
129
+ "Performs exact string replacements in files.\n\n" +
130
+ "Usage:\n" +
131
+ "- You must use your Read tool at least once in the conversation before editing.\n" +
132
+ "- When editing text from Read tool output, preserve the exact indentation.\n" +
133
+ "- ALWAYS prefer editing existing files. NEVER write new files unless required.\n" +
134
+ "- The edit will FAIL if old_string is not unique in the file. Either provide a larger string with more context or use replace_all.",
135
+ promptSnippet: "Edit: targeted string replacement in existing files",
136
+ parameters: editSchema,
137
+ executionMode: "parallel",
138
+ async execute(_toolCallId, params, _signal, _onUpdate) {
139
+ return withWriteLock(async () => {
140
+ const perm = checkPermission("edit", params);
141
+ if (perm.decision === "deny") {
142
+ const result: ToolResultWithError<{ replacements: number; diff: string }> = {
143
+ content: [{ type: "text", text: `Permission denied: ${perm.reason ?? "edit operation blocked"}` }],
144
+ details: { replacements: 0, diff: "" },
145
+ isError: true,
146
+ };
147
+ return result;
148
+ }
149
+ if (perm.decision === "ask") {
150
+ const allowed = await requestPermission({ toolName: "edit", args: params, reason: perm.reason ?? "Confirm file edit" });
151
+ if (!allowed) {
152
+ const result: ToolResultWithError<{ replacements: number; diff: string }> = {
153
+ content: [{ type: "text", text: "User denied permission to edit file." }],
154
+ details: { replacements: 0, diff: "" },
155
+ isError: true,
156
+ };
157
+ return result;
158
+ }
159
+ }
160
+ return await executeEdit(params);
161
+ });
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * GlobTool — Fast file pattern matching
3
+ *
4
+ * Supports glob patterns like "** /*.js" or "src/** /*.ts".
5
+ * Returns matching file paths sorted by modification time.
6
+ */
7
+
8
+ import { Type } from "typebox";
9
+ import { resolve } from "path";
10
+ import { statSync } from "fs";
11
+ import fg from "fast-glob";
12
+ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
13
+ import type { TextContent } from "@mariozechner/pi-ai";
14
+ import { checkPermission } from "../agent/check-permissions.js";
15
+ import { requestPermission } from "../agent/permission-ui.js";
16
+ import type { ToolResultWithError } from "./types.js";
17
+
18
+ export const globSchema = Type.Object({
19
+ pattern: Type.String({ description: "Glob pattern to match files, e.g. '**/*.ts' or 'src/**/*.tsx'" }),
20
+ path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
21
+ });
22
+
23
+ export type GlobToolInput = {
24
+ pattern: string;
25
+ path?: string;
26
+ };
27
+
28
+ const MAX_RESULTS = 100;
29
+
30
+ export async function executeGlob(params: GlobToolInput): Promise<{ content: TextContent[]; details: { count: number; truncated: boolean } }> {
31
+ const cwd = params.path ? resolve(params.path) : process.cwd();
32
+ const entries = await fg(params.pattern, {
33
+ cwd,
34
+ dot: true,
35
+ absolute: false,
36
+ onlyFiles: true,
37
+ });
38
+
39
+ // Sort by modification time (most recent first)
40
+ const withMtime = entries.map((rel) => {
41
+ const full = resolve(cwd, rel);
42
+ try {
43
+ const mtime = statSync(full).mtimeMs;
44
+ return { rel, mtime };
45
+ } catch {
46
+ return { rel, mtime: 0 };
47
+ }
48
+ });
49
+ withMtime.sort((a, b) => b.mtime - a.mtime);
50
+
51
+ const sorted = withMtime.map((e) => e.rel);
52
+ const truncated = sorted.length > MAX_RESULTS;
53
+ const output = sorted.slice(0, MAX_RESULTS);
54
+
55
+ let text = output.join("\n");
56
+ if (truncated) {
57
+ text += `\n\n... (${sorted.length - MAX_RESULTS} more files hidden, limit: ${MAX_RESULTS})`;
58
+ }
59
+
60
+ return {
61
+ content: [{ type: "text", text }],
62
+ details: { count: sorted.length, truncated },
63
+ };
64
+ }
65
+
66
+ export function createGlobToolDefinition(_cwd: string): ToolDefinition<typeof globSchema, { count: number; truncated: boolean }> {
67
+ return {
68
+ name: "glob",
69
+ label: "Glob",
70
+ description:
71
+ "Fast file pattern matching tool that works with any codebase size.\n\n" +
72
+ "- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n" +
73
+ "- Returns matching file paths sorted by modification time\n" +
74
+ "- Use this tool when you need to find files by name patterns",
75
+ promptSnippet: "Glob: find files by glob pattern (**/*.ts, etc.)",
76
+ parameters: globSchema,
77
+ executionMode: "parallel",
78
+ async execute(_toolCallId, params, _signal, _onUpdate) {
79
+ const perm = checkPermission("glob", params);
80
+ if (perm.decision === "deny") {
81
+ const result: ToolResultWithError<{ count: number; truncated: boolean }> = {
82
+ content: [{ type: "text", text: `Permission denied: ${perm.reason ?? "glob operation blocked"}` }],
83
+ details: { count: 0, truncated: false },
84
+ isError: true,
85
+ };
86
+ return result;
87
+ }
88
+ if (perm.decision === "ask") {
89
+ const allowed = await requestPermission({ toolName: "glob", args: params, reason: perm.reason ?? "Confirm file search" });
90
+ if (!allowed) {
91
+ const result: ToolResultWithError<{ count: number; truncated: boolean }> = {
92
+ content: [{ type: "text", text: "User denied permission to search files." }],
93
+ details: { count: 0, truncated: false },
94
+ isError: true,
95
+ };
96
+ return result;
97
+ }
98
+ }
99
+ return await executeGlob(params);
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * GrepTool — ripgrep wrapper for content search
3
+ *
4
+ * Built on ripgrep with safe defaults:
5
+ * --hidden, --glob !VCS, --max-columns 500, auto -e prefix for patterns starting with -
6
+ */
7
+
8
+ import { Type } from "typebox";
9
+ import { spawn } from "child_process";
10
+ import { resolve } from "path";
11
+ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
12
+ import type { TextContent } from "@mariozechner/pi-ai";
13
+ import { checkPermission } from "../agent/check-permissions.js";
14
+ import { requestPermission } from "../agent/permission-ui.js";
15
+ import type { ToolResultWithError } from "./types.js";
16
+
17
+ export const grepSchema = Type.Object({
18
+ pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
19
+ path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
20
+ glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
21
+ output_mode: Type.Optional(Type.Union(
22
+ [Type.Literal("content"), Type.Literal("files_with_matches"), Type.Literal("count")],
23
+ { description: 'Output mode: "content" | "files_with_matches" | "count" (default: content)' }
24
+ )),
25
+ context: Type.Optional(Type.Number({ description: "Number of lines to show before and after each match (default: 0)" })),
26
+ "-i": Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
27
+ multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching (default: false)" })),
28
+ head_limit: Type.Optional(Type.Number({ description: "Maximum number of result lines to return (default: 250)" })),
29
+ });
30
+
31
+ export type GrepToolInput = {
32
+ pattern: string;
33
+ path?: string;
34
+ glob?: string;
35
+ output_mode?: "content" | "files_with_matches" | "count";
36
+ context?: number;
37
+ "-i"?: boolean;
38
+ multiline?: boolean;
39
+ head_limit?: number;
40
+ };
41
+
42
+ const VCS_DIRS = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"];
43
+ const DEFAULT_HEAD_LIMIT = 250;
44
+
45
+ /**
46
+ * Argument Builders
47
+ *
48
+ * buildRgArgs builds ripgrep flags; buildGrepArgs is the POSIX-grep fallback.
49
+ * addVcsExcludes, addModeFlags, addPattern are shared building blocks.
50
+ */
51
+
52
+ function addVcsExcludes(args: string[], cmd: "rg" | "grep"): void {
53
+ if (cmd === "rg") {
54
+ for (const dir of VCS_DIRS) {
55
+ args.push("--glob", `!${dir}`);
56
+ }
57
+ } else {
58
+ for (const dir of VCS_DIRS) {
59
+ args.push("--exclude-dir", dir);
60
+ }
61
+ }
62
+ }
63
+
64
+ function addModeFlags(args: string[], mode: string, cmd: "rg" | "grep", context?: number): void {
65
+ if (mode === "files_with_matches") {
66
+ args.push("-l");
67
+ } else if (mode === "count") {
68
+ args.push("-c");
69
+ }
70
+
71
+ if (mode === "content") {
72
+ if (context !== undefined && context > 0) {
73
+ args.push("-C", String(context));
74
+ }
75
+ if (cmd === "rg") args.push("-n");
76
+ }
77
+ }
78
+
79
+ function addPattern(args: string[], pattern: string): void {
80
+ if (pattern.startsWith("-")) {
81
+ args.push("-e", pattern);
82
+ } else {
83
+ args.push(pattern);
84
+ }
85
+ }
86
+
87
+ function buildRgArgs(input: GrepToolInput): string[] {
88
+ const args: string[] = ["--hidden", "--max-columns", "500"];
89
+ addVcsExcludes(args, "rg");
90
+
91
+ if (input.multiline) args.push("-U", "--multiline-dotall");
92
+ if (input["-i"]) args.push("-i");
93
+
94
+ addModeFlags(args, input.output_mode ?? "content", "rg", input.context);
95
+
96
+ if (input.glob) args.push("--glob", input.glob);
97
+ addPattern(args, input.pattern);
98
+ args.push(input.path ? resolve(input.path) : process.cwd());
99
+
100
+ return args;
101
+ }
102
+
103
+ function buildGrepArgs(input: GrepToolInput): string[] {
104
+ const args: string[] = ["-r", "-n"];
105
+ addVcsExcludes(args, "grep");
106
+
107
+ if (input["-i"]) args.push("-i");
108
+ addModeFlags(args, input.output_mode ?? "content", "grep", input.context);
109
+
110
+ if (input.glob) args.push("--include", input.glob);
111
+ args.push("-e", input.pattern);
112
+ args.push(input.path ? resolve(input.path) : process.cwd());
113
+
114
+ return args;
115
+ }
116
+
117
+ /**
118
+ * Execution
119
+ *
120
+ * Tries ripgrep first; on ENOENT falls back to system grep.
121
+ * Exit code 1 from ripgrep means "no matches" — not an error.
122
+ */
123
+
124
+ interface SearchResult {
125
+ stdout: string;
126
+ stderr: string;
127
+ exitCode: number;
128
+ }
129
+
130
+ function execSearch(cmd: string, args: string[]): Promise<SearchResult> {
131
+ return new Promise((resolve, reject) => {
132
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
133
+ let stdout = "";
134
+ let stderr = "";
135
+
136
+ child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); });
137
+ child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); });
138
+ child.on("error", (err) => reject(err));
139
+ child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 0 }));
140
+ });
141
+ }
142
+
143
+ function truncateOutput(stdout: string, stderr: string, limit: number): { text: string; truncated: boolean } {
144
+ const lines = stdout.split("\n");
145
+ const truncated = lines.length > limit;
146
+ let text = truncated ? lines.slice(0, limit).join("\n") : stdout;
147
+
148
+ if (truncated) {
149
+ text += `\n\n[Showing results with pagination = limit: ${limit}, offset: 0]`;
150
+ }
151
+ if (stderr) {
152
+ text += `\n\n(stderr: ${stderr.trim()})`;
153
+ }
154
+
155
+ return { text, truncated };
156
+ }
157
+
158
+ function countMatches(exitCode: number, outputMode: string | undefined, lines: string[]): number {
159
+ if (exitCode !== 0) return 0;
160
+ if (outputMode === "count") return lines.length - 1;
161
+ return lines.filter((l) => l.trim()).length;
162
+ }
163
+
164
+ export async function executeGrep(params: GrepToolInput): Promise<{
165
+ content: TextContent[];
166
+ details: { matches: number; truncated: boolean };
167
+ }> {
168
+ let result: SearchResult;
169
+
170
+ try {
171
+ result = await execSearch("rg", buildRgArgs(params));
172
+ } catch (err: unknown) {
173
+ if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
174
+ result = await execSearch("grep", buildGrepArgs(params));
175
+ } else {
176
+ throw err;
177
+ }
178
+ }
179
+
180
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
181
+ throw new Error(result.stderr || `ripgrep exited with code ${result.exitCode}`);
182
+ }
183
+
184
+ const limit = params.head_limit ?? DEFAULT_HEAD_LIMIT;
185
+ const { text, truncated } = truncateOutput(result.stdout, result.stderr, limit);
186
+ const matchCount = countMatches(result.exitCode, params.output_mode, result.stdout.split("\n"));
187
+
188
+ return {
189
+ content: [{ type: "text", text }],
190
+ details: { matches: matchCount, truncated },
191
+ };
192
+ }
193
+
194
+ /** Factory helpers for permission-denied tool results. */
195
+
196
+ function buildDeniedResult(): ToolResultWithError<{ matches: number; truncated: boolean }> {
197
+ return {
198
+ content: [{ type: "text", text: "Permission denied: grep operation blocked" }],
199
+ details: { matches: 0, truncated: false },
200
+ isError: true,
201
+ };
202
+ }
203
+
204
+ function buildUserDeniedResult(): ToolResultWithError<{ matches: number; truncated: boolean }> {
205
+ return {
206
+ content: [{ type: "text", text: "User denied permission to search." }],
207
+ details: { matches: 0, truncated: false },
208
+ isError: true,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * ToolDefinition Factory
214
+ *
215
+ * Registers the grep tool with the Pi agent runtime.
216
+ */
217
+
218
+ export function createGrepToolDefinition(_cwd: string): ToolDefinition<typeof grepSchema, { matches: number; truncated: boolean }> {
219
+ return {
220
+ name: "grep",
221
+ label: "Grep",
222
+ description:
223
+ "A powerful search tool built on ripgrep.\n\n" +
224
+ "Usage:\n" +
225
+ "- ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command.\n" +
226
+ "- Supports full regex syntax (e.g. \"log.*Error\", \"function\\s+\\w+\")\n" +
227
+ "- Filter files with glob parameter (e.g. \"*.js\", \"**/*.tsx\")\n" +
228
+ "- Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths, \"count\" shows match counts\n" +
229
+ "- Pattern syntax: Uses ripgrep — literal braces need escaping (use `interface\\{}` to find `interface{}` in Go code)\n" +
230
+ "- Multiline matching: For cross-line patterns, use multiline: true",
231
+ promptSnippet: "Grep: search file contents with ripgrep (regex, glob filters, context lines)",
232
+ parameters: grepSchema,
233
+ executionMode: "parallel",
234
+ async execute(_toolCallId, params, _signal, _onUpdate) {
235
+ const perm = checkPermission("grep", params);
236
+ if (perm.decision === "deny") return buildDeniedResult();
237
+ if (perm.decision === "ask") {
238
+ const allowed = await requestPermission({
239
+ toolName: "grep",
240
+ args: params as GrepToolInput,
241
+ reason: perm.reason ?? "Confirm search",
242
+ });
243
+ if (!allowed) return buildUserDeniedResult();
244
+ }
245
+ return await executeGrep(params);
246
+ },
247
+ };
248
+ }