tinker-agent 1.3.0 → 1.4.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 +17 -1
- package/README.md +217 -72
- package/bin/tinker.js +75 -25
- package/package.json +8 -3
- package/src/agent/runtime-session.ts +34 -15
- package/src/cli/command-line.ts +291 -0
- package/src/cli/config.ts +131 -264
- package/src/cli/index.ts +33 -21
- package/src/cli/main.ts +213 -0
- package/src/cli/model-profiles.ts +143 -72
- package/src/cli/output.ts +113 -0
- package/src/cli/package-metadata.ts +36 -0
- package/src/cli/prompt-source.ts +229 -0
- package/src/cli/public-cli-contract.ts +69 -0
- package/src/cli/public-config-contract.ts +650 -0
- package/src/cli/run-runner.ts +17 -12
- package/src/cli/runner-dependencies.ts +100 -0
- package/src/cli/tui-runner.tsx +52 -49
- package/src/mcp/mcp-manager.ts +2 -19
- package/src/mcp/mcp-tool-executor.ts +3 -4
- package/src/model/model-context-profile.ts +0 -30
- package/src/tools/bash.ts +8 -25
- package/src/tools/grep.ts +9 -1
- package/src/tools/registry.ts +15 -1
- package/src/tools/ripgrep.ts +24 -27
- package/src/tools/web-fetch/index.ts +2 -15
- package/src/tui/app.tsx +3 -0
- package/src/tui/components/prompt-input.tsx +6 -3
- package/src/tui/slash-commands.ts +76 -24
- package/src/tui/workspace-file-search.ts +78 -71
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { formatDiagnosticPath } from "./output";
|
|
5
|
+
|
|
6
|
+
export const MAX_ONESHOT_PROMPT_BYTES = 1 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
9
|
+
const USER_CORRECTABLE_FILE_ERRORS = new Set([
|
|
10
|
+
"EACCES",
|
|
11
|
+
"EISDIR",
|
|
12
|
+
"ELOOP",
|
|
13
|
+
"ENAMETOOLONG",
|
|
14
|
+
"ENOENT",
|
|
15
|
+
"ENOTDIR",
|
|
16
|
+
"ENXIO",
|
|
17
|
+
"EPERM",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export type PromptSource =
|
|
21
|
+
| { readonly kind: "argument"; readonly value: string }
|
|
22
|
+
| { readonly kind: "stdin" }
|
|
23
|
+
| { readonly kind: "file"; readonly filePath: string };
|
|
24
|
+
|
|
25
|
+
export type ResolvedPrompt = {
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly byteLength: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PromptReadable = AsyncIterable<unknown>;
|
|
31
|
+
|
|
32
|
+
export class PromptInputError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly exitCode: 1 | 2,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "PromptInputError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resolvePromptSource(
|
|
43
|
+
source: PromptSource,
|
|
44
|
+
input: {
|
|
45
|
+
readonly stdin: PromptReadable;
|
|
46
|
+
readonly cwd: string;
|
|
47
|
+
},
|
|
48
|
+
dependencies: {
|
|
49
|
+
readonly openFile?: typeof open;
|
|
50
|
+
} = {},
|
|
51
|
+
): Promise<ResolvedPrompt> {
|
|
52
|
+
if (source.kind === "argument") {
|
|
53
|
+
return validateArgumentPrompt(source.value);
|
|
54
|
+
}
|
|
55
|
+
if (source.kind === "stdin") {
|
|
56
|
+
return validatePromptBytes(await readStdinBytes(input.stdin), "standard input");
|
|
57
|
+
}
|
|
58
|
+
if (source.filePath.length === 0) {
|
|
59
|
+
throw new PromptInputError("--file requires a non-empty path.", 2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filePath = source.filePath;
|
|
63
|
+
const resolvedPath = path.resolve(input.cwd, filePath);
|
|
64
|
+
const displayPath = formatDiagnosticPath(filePath);
|
|
65
|
+
const openFile = dependencies.openFile ?? open;
|
|
66
|
+
let handle: FileHandle;
|
|
67
|
+
try {
|
|
68
|
+
handle = await openFile(resolvedPath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw fileOperationError(error, displayPath, "open");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let bytes: Buffer | undefined;
|
|
74
|
+
let readError: PromptInputError | undefined;
|
|
75
|
+
try {
|
|
76
|
+
const stats = await handle.stat();
|
|
77
|
+
if (!stats.isFile()) {
|
|
78
|
+
throw new PromptInputError(
|
|
79
|
+
`Prompt file ${displayPath} must be a regular file.`,
|
|
80
|
+
2,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (stats.size > MAX_ONESHOT_PROMPT_BYTES) {
|
|
84
|
+
throw promptTooLarge("file", stats.size, displayPath);
|
|
85
|
+
}
|
|
86
|
+
bytes = await readFileBytes(handle, displayPath);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
readError =
|
|
89
|
+
error instanceof PromptInputError
|
|
90
|
+
? error
|
|
91
|
+
: fileOperationError(error, displayPath, "read");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await handle.close();
|
|
96
|
+
} catch {
|
|
97
|
+
throw new PromptInputError(`Could not close prompt file ${displayPath}.`, 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (readError !== undefined) {
|
|
101
|
+
throw readError;
|
|
102
|
+
}
|
|
103
|
+
if (bytes === undefined) {
|
|
104
|
+
throw new PromptInputError(`Could not read prompt file ${displayPath}.`, 1);
|
|
105
|
+
}
|
|
106
|
+
return validatePromptBytes(bytes, `file ${displayPath}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateArgumentPrompt(text: string): ResolvedPrompt {
|
|
110
|
+
const byteLength = Buffer.byteLength(text);
|
|
111
|
+
if (byteLength > MAX_ONESHOT_PROMPT_BYTES) {
|
|
112
|
+
throw promptTooLarge("argument", byteLength);
|
|
113
|
+
}
|
|
114
|
+
validatePromptText(text, "argument");
|
|
115
|
+
return Object.freeze({ text, byteLength });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readStdinBytes(stdin: PromptReadable): Promise<Buffer> {
|
|
119
|
+
const chunks: Buffer[] = [];
|
|
120
|
+
let byteLength = 0;
|
|
121
|
+
try {
|
|
122
|
+
for await (const rawChunk of stdin) {
|
|
123
|
+
const chunk = toBuffer(rawChunk);
|
|
124
|
+
const nextLength = byteLength + chunk.byteLength;
|
|
125
|
+
if (nextLength > MAX_ONESHOT_PROMPT_BYTES) {
|
|
126
|
+
throw promptTooLarge("standard input", nextLength);
|
|
127
|
+
}
|
|
128
|
+
chunks.push(chunk);
|
|
129
|
+
byteLength = nextLength;
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof PromptInputError) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
throw new PromptInputError("Could not read prompt from standard input.", 1);
|
|
136
|
+
}
|
|
137
|
+
return Buffer.concat(chunks, byteLength);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readFileBytes(handle: FileHandle, displayPath: string): Promise<Buffer> {
|
|
141
|
+
const chunks: Buffer[] = [];
|
|
142
|
+
let byteLength = 0;
|
|
143
|
+
while (true) {
|
|
144
|
+
const remaining = MAX_ONESHOT_PROMPT_BYTES + 1 - byteLength;
|
|
145
|
+
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining));
|
|
146
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
|
|
147
|
+
if (bytesRead === 0) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
byteLength += bytesRead;
|
|
151
|
+
if (byteLength > MAX_ONESHOT_PROMPT_BYTES) {
|
|
152
|
+
throw promptTooLarge("file", byteLength, displayPath);
|
|
153
|
+
}
|
|
154
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
155
|
+
}
|
|
156
|
+
return Buffer.concat(chunks, byteLength);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validatePromptBytes(bytes: Buffer, source: string): ResolvedPrompt {
|
|
160
|
+
let text: string;
|
|
161
|
+
try {
|
|
162
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new PromptInputError(`Prompt from ${source} is not valid UTF-8.`, 2);
|
|
165
|
+
}
|
|
166
|
+
validatePromptText(text, source);
|
|
167
|
+
return Object.freeze({ text, byteLength: bytes.byteLength });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validatePromptText(text: string, source: string): void {
|
|
171
|
+
if (text.includes("\0")) {
|
|
172
|
+
throw new PromptInputError(`Prompt from ${source} contains a NUL byte.`, 2);
|
|
173
|
+
}
|
|
174
|
+
if (text.trim().length === 0) {
|
|
175
|
+
throw new PromptInputError(`Prompt from ${source} must not be empty.`, 2);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function promptTooLarge(
|
|
180
|
+
source: string,
|
|
181
|
+
actualBytes: number,
|
|
182
|
+
displayPath?: string,
|
|
183
|
+
): PromptInputError {
|
|
184
|
+
const location = displayPath === undefined ? source : `${source} ${displayPath}`;
|
|
185
|
+
return new PromptInputError(
|
|
186
|
+
`Prompt from ${location} is ${actualBytes} bytes; the limit is ${MAX_ONESHOT_PROMPT_BYTES} bytes.`,
|
|
187
|
+
2,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fileOperationError(
|
|
192
|
+
error: unknown,
|
|
193
|
+
displayPath: string,
|
|
194
|
+
operation: "open" | "read",
|
|
195
|
+
): PromptInputError {
|
|
196
|
+
const code = errorCode(error);
|
|
197
|
+
if (code !== undefined && USER_CORRECTABLE_FILE_ERRORS.has(code)) {
|
|
198
|
+
return new PromptInputError(
|
|
199
|
+
`Prompt file ${displayPath} is not available (${code}).`,
|
|
200
|
+
2,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return new PromptInputError(
|
|
204
|
+
`Could not ${operation} prompt file ${displayPath}${code === undefined ? "." : ` (${code}).`}`,
|
|
205
|
+
1,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function errorCode(error: unknown): string | undefined {
|
|
210
|
+
if (
|
|
211
|
+
typeof error === "object" &&
|
|
212
|
+
error !== null &&
|
|
213
|
+
"code" in error &&
|
|
214
|
+
typeof error.code === "string"
|
|
215
|
+
) {
|
|
216
|
+
return error.code;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function toBuffer(chunk: unknown): Buffer {
|
|
222
|
+
if (typeof chunk === "string") {
|
|
223
|
+
return Buffer.from(chunk);
|
|
224
|
+
}
|
|
225
|
+
if (chunk instanceof Uint8Array) {
|
|
226
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
227
|
+
}
|
|
228
|
+
throw new TypeError("Prompt stdin produced a non-byte chunk.");
|
|
229
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type PublicCliOption = {
|
|
2
|
+
readonly flags: string;
|
|
3
|
+
readonly description: string;
|
|
4
|
+
readonly valueName?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type PublicPromptSource = {
|
|
8
|
+
readonly kind: "argument" | "stdin" | "file";
|
|
9
|
+
readonly syntax: string;
|
|
10
|
+
readonly description: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const PROFILE_OPTION = Object.freeze({
|
|
14
|
+
flags: "-p, --profile <profile-name>",
|
|
15
|
+
description: "Select a model profile.",
|
|
16
|
+
valueName: "profile-name",
|
|
17
|
+
} satisfies PublicCliOption);
|
|
18
|
+
|
|
19
|
+
const RUN_PROMPT_SOURCES = Object.freeze([
|
|
20
|
+
Object.freeze({
|
|
21
|
+
kind: "argument",
|
|
22
|
+
syntax: "<prompt>",
|
|
23
|
+
description: "Submit one shell-quoted prompt argument.",
|
|
24
|
+
}),
|
|
25
|
+
Object.freeze({
|
|
26
|
+
kind: "stdin",
|
|
27
|
+
syntax: "--stdin",
|
|
28
|
+
description: "Read the prompt from standard input until EOF.",
|
|
29
|
+
}),
|
|
30
|
+
Object.freeze({
|
|
31
|
+
kind: "file",
|
|
32
|
+
syntax: "--file <path>",
|
|
33
|
+
description: "Read the prompt from a UTF-8 text file.",
|
|
34
|
+
}),
|
|
35
|
+
] satisfies readonly PublicPromptSource[]);
|
|
36
|
+
|
|
37
|
+
export const PUBLIC_CLI_CONTRACT = Object.freeze({
|
|
38
|
+
name: "tinker",
|
|
39
|
+
description: "A personal coding agent for a local workspace.",
|
|
40
|
+
helpFlags: "-h, --help",
|
|
41
|
+
versionFlags: "-V, --version",
|
|
42
|
+
helpCommand: Object.freeze({
|
|
43
|
+
command: "help [command]",
|
|
44
|
+
description: "display help for command",
|
|
45
|
+
}),
|
|
46
|
+
tui: Object.freeze({
|
|
47
|
+
description: "Start the interactive terminal interface.",
|
|
48
|
+
profileOption: PROFILE_OPTION,
|
|
49
|
+
}),
|
|
50
|
+
run: Object.freeze({
|
|
51
|
+
command: "run [prompt]",
|
|
52
|
+
description: "Run one prompt non-interactively.",
|
|
53
|
+
profileOption: PROFILE_OPTION,
|
|
54
|
+
stdinOption: Object.freeze({
|
|
55
|
+
flags: "--stdin",
|
|
56
|
+
description: "Read the prompt from standard input until EOF.",
|
|
57
|
+
} satisfies PublicCliOption),
|
|
58
|
+
fileOption: Object.freeze({
|
|
59
|
+
flags: "--file <path>",
|
|
60
|
+
description: "Read the prompt from a UTF-8 text file.",
|
|
61
|
+
valueName: "path",
|
|
62
|
+
} satisfies PublicCliOption),
|
|
63
|
+
promptSources: RUN_PROMPT_SOURCES,
|
|
64
|
+
helpAfter:
|
|
65
|
+
"Use exactly one prompt source. For complex or sensitive prompts, prefer --stdin or --file.",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type PublicCliContract = typeof PUBLIC_CLI_CONTRACT;
|