tinker-agent 1.2.1 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +217 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +8 -3
  5. package/src/agent/loop.ts +115 -11
  6. package/src/agent/runtime-session.ts +34 -15
  7. package/src/cli/command-line.ts +291 -0
  8. package/src/cli/config.ts +131 -264
  9. package/src/cli/index.ts +33 -21
  10. package/src/cli/main.ts +213 -0
  11. package/src/cli/model-profiles.ts +143 -72
  12. package/src/cli/output.ts +113 -0
  13. package/src/cli/package-metadata.ts +36 -0
  14. package/src/cli/prompt-source.ts +229 -0
  15. package/src/cli/public-cli-contract.ts +69 -0
  16. package/src/cli/public-config-contract.ts +650 -0
  17. package/src/cli/run-runner.ts +17 -12
  18. package/src/cli/runner-dependencies.ts +100 -0
  19. package/src/cli/tui-runner.tsx +52 -49
  20. package/src/events/observation-text-log.ts +2 -0
  21. package/src/events/stdout-event-printer.ts +7 -1
  22. package/src/events/types.ts +27 -3
  23. package/src/mcp/mcp-manager.ts +2 -19
  24. package/src/mcp/mcp-tool-executor.ts +3 -4
  25. package/src/model/model-client.ts +29 -0
  26. package/src/model/model-context-profile.ts +0 -30
  27. package/src/model/openai-chat-mapping.ts +54 -15
  28. package/src/model/openai-chat-model-client.ts +46 -27
  29. package/src/model/openai-chat-stream.ts +6 -2
  30. package/src/tools/bash.ts +8 -25
  31. package/src/tools/grep.ts +9 -1
  32. package/src/tools/registry.ts +15 -1
  33. package/src/tools/ripgrep.ts +24 -27
  34. package/src/tools/web-fetch/index.ts +2 -15
  35. package/src/tui/app.tsx +3 -0
  36. package/src/tui/components/prompt-input.tsx +6 -3
  37. package/src/tui/event-store.ts +24 -7
  38. package/src/tui/slash-commands.ts +76 -24
  39. package/src/tui/workspace-file-search.ts +78 -71
@@ -0,0 +1,213 @@
1
+ import type { SessionId } from "../ids/runtime-id";
2
+ import { createUuidV7 } from "../ids/uuid-v7";
3
+ import { parseCommandLine, type CommandLineResult } from "./command-line";
4
+ import type {
5
+ ResolvedPublicConfig,
6
+ RunnerConfig,
7
+ RunnerConfigSelection,
8
+ } from "./config";
9
+ import {
10
+ CliUsageError,
11
+ flushCliOutput,
12
+ renderCliFailure,
13
+ renderUsageError,
14
+ writeCliOutput,
15
+ type CliOutputWriter,
16
+ } from "./output";
17
+ import { loadPackageMetadata, type PackageMetadata } from "./package-metadata";
18
+ import {
19
+ PromptInputError,
20
+ resolvePromptSource,
21
+ type PromptReadable,
22
+ type PromptSource,
23
+ type ResolvedPrompt,
24
+ } from "./prompt-source";
25
+
26
+ export const BOOTSTRAP_FAILURE_MESSAGE =
27
+ "Tinker failed to start. Reinstall tinker-agent.\n";
28
+
29
+ export type MainInput = {
30
+ readonly args: readonly string[];
31
+ readonly stdin: PromptReadable;
32
+ readonly stdout: CliOutputWriter;
33
+ readonly stderr: CliOutputWriter;
34
+ readonly cwd: string;
35
+ readonly env: NodeJS.ProcessEnv;
36
+ };
37
+
38
+ type ConfigBoundary = {
39
+ readonly resolvePublicConfig: (input: {
40
+ readonly env: NodeJS.ProcessEnv;
41
+ readonly cwd: string;
42
+ }) => Promise<ResolvedPublicConfig>;
43
+ readonly deriveRunnerConfig: (
44
+ snapshot: ResolvedPublicConfig,
45
+ selection: RunnerConfigSelection,
46
+ ) => RunnerConfig;
47
+ };
48
+
49
+ type TuiRunner = {
50
+ readonly runTui: (options: {
51
+ readonly publicConfig: ResolvedPublicConfig;
52
+ readonly initialRunnerConfig: RunnerConfig;
53
+ readonly env: NodeJS.ProcessEnv;
54
+ }) => Promise<void>;
55
+ };
56
+
57
+ type OneShotRunner = {
58
+ readonly runOneShot: (
59
+ prompt: string,
60
+ options: {
61
+ readonly config: RunnerConfig;
62
+ readonly tooling: ResolvedPublicConfig["tooling"];
63
+ readonly stdout: CliOutputWriter;
64
+ readonly stderr: CliOutputWriter;
65
+ readonly env: NodeJS.ProcessEnv;
66
+ },
67
+ ) => Promise<number>;
68
+ };
69
+
70
+ export type MainDependencies = {
71
+ readonly loadPackageMetadata: () => Promise<PackageMetadata>;
72
+ readonly parseCommandLine: (
73
+ args: readonly string[],
74
+ packageVersion: string,
75
+ ) => Promise<CommandLineResult>;
76
+ readonly loadConfigBoundary: () => Promise<ConfigBoundary>;
77
+ readonly createSessionId: () => SessionId;
78
+ readonly resolvePromptSource: (
79
+ source: PromptSource,
80
+ input: { readonly stdin: PromptReadable; readonly cwd: string },
81
+ ) => Promise<ResolvedPrompt>;
82
+ readonly loadTuiRunner: () => Promise<TuiRunner>;
83
+ readonly loadOneShotRunner: () => Promise<OneShotRunner>;
84
+ };
85
+
86
+ const DEFAULT_DEPENDENCIES: MainDependencies = {
87
+ loadPackageMetadata,
88
+ parseCommandLine,
89
+ loadConfigBoundary: () => import("./config"),
90
+ createSessionId: () => createUuidV7() as SessionId,
91
+ resolvePromptSource,
92
+ loadTuiRunner: () => import("./tui-runner"),
93
+ loadOneShotRunner: () => import("./run-runner"),
94
+ };
95
+
96
+ export async function main(
97
+ input: MainInput,
98
+ injected: Partial<MainDependencies> = {},
99
+ ): Promise<number> {
100
+ const dependencies = { ...DEFAULT_DEPENDENCIES, ...injected };
101
+ const args = Object.freeze([...input.args]);
102
+ const env = Object.freeze({ ...input.env }) as NodeJS.ProcessEnv;
103
+ const cwd = input.cwd;
104
+ const finish = async (exitCode: number): Promise<number> => {
105
+ await flushCliOutput(input.stdout);
106
+ await flushCliOutput(input.stderr);
107
+ return exitCode;
108
+ };
109
+
110
+ try {
111
+ let metadata: PackageMetadata;
112
+ try {
113
+ metadata = await dependencies.loadPackageMetadata();
114
+ } catch {
115
+ await writeCliOutput(input.stderr, BOOTSTRAP_FAILURE_MESSAGE);
116
+ return finish(1);
117
+ }
118
+
119
+ let parsed: CommandLineResult;
120
+ try {
121
+ parsed = await dependencies.parseCommandLine(args, metadata.version);
122
+ } catch (error) {
123
+ if (error instanceof CliUsageError) {
124
+ await writeCliOutput(input.stderr, renderUsageError(error));
125
+ return finish(2);
126
+ }
127
+ throw error;
128
+ }
129
+
130
+ if (parsed.type === "terminal") {
131
+ await writeCliOutput(input.stdout, parsed.stdout);
132
+ await writeCliOutput(input.stderr, parsed.stderr);
133
+ return finish(0);
134
+ }
135
+
136
+ let configBoundary: ConfigBoundary;
137
+ let publicConfig: ResolvedPublicConfig;
138
+ let runnerConfig: RunnerConfig;
139
+ try {
140
+ configBoundary = await dependencies.loadConfigBoundary();
141
+ publicConfig = await configBoundary.resolvePublicConfig({ env, cwd });
142
+ runnerConfig = configBoundary.deriveRunnerConfig(publicConfig, {
143
+ sessionId: dependencies.createSessionId(),
144
+ ...(parsed.command.profileName === undefined
145
+ ? {}
146
+ : { profileName: parsed.command.profileName }),
147
+ });
148
+ } catch (error) {
149
+ await writeCliOutput(
150
+ input.stderr,
151
+ renderCliFailure("Configuration failed", error),
152
+ );
153
+ return finish(1);
154
+ }
155
+
156
+ if (parsed.command.type === "tui") {
157
+ try {
158
+ const runner = await dependencies.loadTuiRunner();
159
+ await runner.runTui({
160
+ publicConfig,
161
+ initialRunnerConfig: runnerConfig,
162
+ env,
163
+ });
164
+ return finish(0);
165
+ } catch (error) {
166
+ await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
167
+ return finish(1);
168
+ }
169
+ }
170
+
171
+ let prompt: ResolvedPrompt;
172
+ try {
173
+ prompt = await dependencies.resolvePromptSource(parsed.command.promptSource, {
174
+ stdin: input.stdin,
175
+ cwd,
176
+ });
177
+ } catch (error) {
178
+ if (error instanceof PromptInputError) {
179
+ await writeCliOutput(
180
+ input.stderr,
181
+ renderCliFailure("Prompt input failed", error),
182
+ );
183
+ return finish(error.exitCode);
184
+ }
185
+ await writeCliOutput(
186
+ input.stderr,
187
+ renderCliFailure("Prompt input failed", error),
188
+ );
189
+ return finish(1);
190
+ }
191
+
192
+ try {
193
+ const runner = await dependencies.loadOneShotRunner();
194
+ const exitCode = await runner.runOneShot(prompt.text, {
195
+ config: runnerConfig,
196
+ tooling: publicConfig.tooling,
197
+ stdout: input.stdout,
198
+ stderr: input.stderr,
199
+ env,
200
+ });
201
+ return finish(exitCode);
202
+ } catch (error) {
203
+ await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
204
+ return finish(1);
205
+ }
206
+ } catch (error) {
207
+ await writeCliOutput(
208
+ input.stderr,
209
+ renderCliFailure("Tinker failed unexpectedly", error),
210
+ );
211
+ return finish(1);
212
+ }
213
+ }
@@ -1,9 +1,15 @@
1
- import path from "node:path";
2
1
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
2
  import {
4
3
  createModelContextProfile,
5
4
  type ModelContextProfile,
6
5
  } from "../model/model-context-profile";
6
+ import {
7
+ MODEL_PROFILE_FIELDS,
8
+ MODEL_PROFILES_DOCUMENT_FIELDS,
9
+ MODEL_TOKEN_ESTIMATOR_FIELDS,
10
+ type ModelTokenEstimatorKind,
11
+ type ModelTokenEstimatorMaxRetries,
12
+ } from "./public-config-contract";
7
13
 
8
14
  export type ModelProfile = {
9
15
  readonly name: string;
@@ -21,12 +27,12 @@ export type ModelProfile = {
21
27
  export type ModelInputModality = "text" | "image";
22
28
 
23
29
  export type ModelTokenEstimatorProfile = {
24
- readonly kind: "moonshot-estimate-token-count-v1";
30
+ readonly kind: ModelTokenEstimatorKind;
25
31
  readonly model: string;
26
32
  readonly apiBase: string;
27
33
  readonly apiKey: string;
28
34
  readonly timeoutMs: number;
29
- readonly maxRetries: 0;
35
+ readonly maxRetries: ModelTokenEstimatorMaxRetries;
30
36
  };
31
37
 
32
38
  export type ModelProfiles = {
@@ -34,24 +40,7 @@ export type ModelProfiles = {
34
40
  readonly profiles: ReadonlyMap<string, ModelProfile>;
35
41
  };
36
42
 
37
- export function modelsConfigPath(
38
- env: NodeJS.ProcessEnv = process.env,
39
- ): string | undefined {
40
- const value = env.TINKER_MODELS;
41
- if (value === undefined || value.trim() === "") {
42
- return undefined;
43
- }
44
- return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
45
- }
46
-
47
- export async function loadModelProfiles(
48
- env: NodeJS.ProcessEnv = process.env,
49
- ): Promise<ModelProfiles | undefined> {
50
- const configPath = modelsConfigPath(env);
51
- if (configPath === undefined) {
52
- return undefined;
53
- }
54
-
43
+ export async function loadModelProfiles(configPath: string): Promise<ModelProfiles> {
55
44
  let raw: string;
56
45
  try {
57
46
  raw = await readFile(configPath, "utf8");
@@ -67,13 +56,8 @@ export async function loadModelProfiles(
67
56
 
68
57
  export async function persistDefaultProfile(
69
58
  profileName: string,
70
- env: NodeJS.ProcessEnv = process.env,
59
+ configPath: string,
71
60
  ): Promise<void> {
72
- const configPath = modelsConfigPath(env);
73
- if (configPath === undefined) {
74
- return;
75
- }
76
-
77
61
  let raw: string;
78
62
  try {
79
63
  raw = await readFile(configPath, "utf8");
@@ -128,6 +112,11 @@ export function parseModelProfiles(raw: string, sourcePath: string): ModelProfil
128
112
  if (!isRecord(json)) {
129
113
  throw new Error(`Model profiles ${sourcePath} must be a JSON object.`);
130
114
  }
115
+ assertKnownKeys(
116
+ json,
117
+ MODEL_PROFILES_DOCUMENT_FIELDS.map((field) => field.name),
118
+ `Model profiles ${sourcePath}`,
119
+ );
131
120
 
132
121
  const defaultProfile = json.default;
133
122
  if (typeof defaultProfile !== "string" || defaultProfile.trim() === "") {
@@ -238,45 +227,31 @@ function parseProfile(
238
227
 
239
228
  assertKnownKeys(
240
229
  value,
241
- [
242
- "model",
243
- "apiBase",
244
- "apiKey",
245
- "contextWindowTokens",
246
- "maxSupportedOutputTokens",
247
- "includeReasoningContent",
248
- "stream",
249
- "inputModalities",
250
- "tokenEstimator",
251
- ],
230
+ MODEL_PROFILE_FIELDS.map((field) => field.name),
252
231
  where,
253
232
  );
254
233
 
255
- const model = requireString(value.model, `${where}: "model"`);
256
- const apiBase = requireString(value.apiBase, `${where}: "apiBase"`);
257
- const apiKey = requireString(value.apiKey, `${where}: "apiKey"`);
234
+ const model = parseProfileString(value, "model", where);
235
+ const apiBase = parseProfileString(value, "apiBase", where);
236
+ const apiKey = parseProfileString(value, "apiKey", where);
258
237
 
259
- const contextWindowTokens = requirePositiveInteger(
260
- value.contextWindowTokens,
261
- `${where}: "contextWindowTokens"`,
238
+ const contextWindowTokens = parseProfilePositiveInteger(
239
+ value,
240
+ "contextWindowTokens",
241
+ where,
262
242
  );
263
- const maxSupportedOutputTokens = requirePositiveInteger(
264
- value.maxSupportedOutputTokens,
265
- `${where}: "maxSupportedOutputTokens"`,
243
+ const maxSupportedOutputTokens = parseProfilePositiveInteger(
244
+ value,
245
+ "maxSupportedOutputTokens",
246
+ where,
266
247
  );
267
248
 
268
- const includeReasoningContent =
269
- value.includeReasoningContent === undefined
270
- ? false
271
- : parseBoolean(
272
- value.includeReasoningContent,
273
- `${where}: "includeReasoningContent"`,
274
- );
275
-
276
- const stream =
277
- value.stream === undefined
278
- ? true
279
- : parseBoolean(value.stream, `${where}: "stream"`);
249
+ const includeReasoningContent = parseProfileBoolean(
250
+ value,
251
+ "includeReasoningContent",
252
+ where,
253
+ );
254
+ const stream = parseProfileBoolean(value, "stream", where);
280
255
 
281
256
  const inputModalities = parseInputModalities(
282
257
  value.inputModalities,
@@ -316,7 +291,8 @@ function parseInputModalities(
316
291
  name: string,
317
292
  ): readonly ModelInputModality[] {
318
293
  if (value === undefined) {
319
- return Object.freeze(["text"]);
294
+ const defaultValue = modelProfileField("inputModalities").defaultValue;
295
+ return defaultValue;
320
296
  }
321
297
  if (!Array.isArray(value) || value.length === 0) {
322
298
  throw new Error(`${name} must be a non-empty array.`);
@@ -345,32 +321,127 @@ function parseTokenEstimator(value: unknown, name: string): ModelTokenEstimatorP
345
321
  }
346
322
  assertKnownKeys(
347
323
  value,
348
- ["kind", "model", "apiBase", "apiKey", "timeoutMs", "maxRetries"],
324
+ MODEL_TOKEN_ESTIMATOR_FIELDS.map((field) => field.name),
349
325
  name,
350
326
  );
351
- if (value.kind !== "moonshot-estimate-token-count-v1") {
352
- throw new Error(`${name}.kind must be "moonshot-estimate-token-count-v1".`);
327
+ const kindField = tokenEstimatorField("kind");
328
+ if (value.kind !== kindField.literalValue) {
329
+ throw new Error(`${name}.kind must be ${JSON.stringify(kindField.literalValue)}.`);
330
+ }
331
+ const model = parseTokenEstimatorString(value, "model", name);
332
+ const apiBase = parseTokenEstimatorString(value, "apiBase", name);
333
+ const apiKey = parseTokenEstimatorString(value, "apiKey", name);
334
+ const timeoutField = tokenEstimatorField("timeoutMs");
335
+ if (timeoutField.valueKind !== "positive-integer") {
336
+ throw new Error("Token estimator timeoutMs contract kind is invalid.");
353
337
  }
354
- const model = requireString(value.model, `${name}.model`);
355
- const apiBase = requireString(value.apiBase, `${name}.apiBase`);
356
- const apiKey = requireString(value.apiKey, `${name}.apiKey`);
357
338
  const timeoutMs = requirePositiveInteger(value.timeoutMs, `${name}.timeoutMs`);
358
- if (timeoutMs < 1_000 || timeoutMs > 60_000) {
359
- throw new Error(`${name}.timeoutMs must be between 1000 and 60000.`);
339
+ if (
340
+ timeoutField.minimum === undefined ||
341
+ timeoutField.maximum === undefined ||
342
+ timeoutMs < timeoutField.minimum ||
343
+ timeoutMs > timeoutField.maximum
344
+ ) {
345
+ throw new Error(
346
+ `${name}.timeoutMs must be between ${timeoutField.minimum} and ${timeoutField.maximum}.`,
347
+ );
360
348
  }
361
- if (value.maxRetries !== 0) {
362
- throw new Error(`${name}.maxRetries must be 0.`);
349
+ const maxRetriesField = tokenEstimatorField("maxRetries");
350
+ if (value.maxRetries !== maxRetriesField.literalValue) {
351
+ throw new Error(`${name}.maxRetries must be ${maxRetriesField.literalValue}.`);
363
352
  }
364
353
  return Object.freeze({
365
- kind: value.kind,
354
+ kind: kindField.literalValue,
366
355
  model,
367
356
  apiBase,
368
357
  apiKey,
369
358
  timeoutMs,
370
- maxRetries: 0,
359
+ maxRetries: maxRetriesField.literalValue,
371
360
  });
372
361
  }
373
362
 
363
+ type ModelProfileFieldName = (typeof MODEL_PROFILE_FIELDS)[number]["name"];
364
+ type ModelTokenEstimatorFieldName =
365
+ (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number]["name"];
366
+
367
+ function modelProfileField<Name extends ModelProfileFieldName>(
368
+ name: Name,
369
+ ): Extract<(typeof MODEL_PROFILE_FIELDS)[number], { readonly name: Name }> {
370
+ const field = MODEL_PROFILE_FIELDS.find((candidate) => candidate.name === name);
371
+ if (field === undefined) {
372
+ throw new Error(`Missing model profile field contract for ${name}.`);
373
+ }
374
+ return field as Extract<
375
+ (typeof MODEL_PROFILE_FIELDS)[number],
376
+ { readonly name: Name }
377
+ >;
378
+ }
379
+
380
+ function tokenEstimatorField<Name extends ModelTokenEstimatorFieldName>(
381
+ name: Name,
382
+ ): Extract<(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number], { readonly name: Name }> {
383
+ const field = MODEL_TOKEN_ESTIMATOR_FIELDS.find(
384
+ (candidate) => candidate.name === name,
385
+ );
386
+ if (field === undefined) {
387
+ throw new Error(`Missing token estimator field contract for ${name}.`);
388
+ }
389
+ return field as Extract<
390
+ (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
391
+ { readonly name: Name }
392
+ >;
393
+ }
394
+
395
+ function parseProfileString(
396
+ value: Record<string, unknown>,
397
+ name: "model" | "apiBase" | "apiKey",
398
+ where: string,
399
+ ): string {
400
+ const field = modelProfileField(name);
401
+ if (field.valueKind !== "non-empty-string") {
402
+ throw new Error(`Model profile field ${name} has an invalid contract kind.`);
403
+ }
404
+ return requireString(value[name], `${where}: ${JSON.stringify(name)}`);
405
+ }
406
+
407
+ function parseProfilePositiveInteger(
408
+ value: Record<string, unknown>,
409
+ name: "contextWindowTokens" | "maxSupportedOutputTokens",
410
+ where: string,
411
+ ): number {
412
+ const field = modelProfileField(name);
413
+ if (field.valueKind !== "positive-integer") {
414
+ throw new Error(`Model profile field ${name} has an invalid contract kind.`);
415
+ }
416
+ return requirePositiveInteger(value[name], `${where}: ${JSON.stringify(name)}`);
417
+ }
418
+
419
+ function parseProfileBoolean(
420
+ value: Record<string, unknown>,
421
+ name: "includeReasoningContent" | "stream",
422
+ where: string,
423
+ ): boolean {
424
+ const field = modelProfileField(name);
425
+ if (field.valueKind !== "boolean" || typeof field.defaultValue !== "boolean") {
426
+ throw new Error(`Model profile field ${name} has an invalid boolean contract.`);
427
+ }
428
+ return value[name] === undefined
429
+ ? field.defaultValue
430
+ : parseBoolean(value[name], `${where}: ${JSON.stringify(name)}`);
431
+ }
432
+
433
+ function parseTokenEstimatorString(
434
+ value: Record<string, unknown>,
435
+ name: "model" | "apiBase" | "apiKey",
436
+ where: string,
437
+ ): string {
438
+ const field = tokenEstimatorField(name);
439
+ if (field.valueKind !== "non-empty-string") {
440
+ throw new Error(`Token estimator field ${name} has an invalid contract kind.`);
441
+ }
442
+ return requireString(value[name], `${where}.${name}`);
443
+ }
444
+
374
445
  function assertKnownKeys(
375
446
  value: Record<string, unknown>,
376
447
  allowed: readonly string[],
@@ -0,0 +1,113 @@
1
+ export const MAX_CLI_DIAGNOSTIC_DETAIL_BYTES = 512;
2
+
3
+ const TRUNCATION_MARKER = "...[truncated]";
4
+ const ESCAPE = String.fromCharCode(27);
5
+ const ANSI_CSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
6
+
7
+ export type CliCommandScope = "root" | "run";
8
+
9
+ export interface CliOutputWriter {
10
+ write(chunk: string): boolean | void;
11
+ readonly writableNeedDrain?: boolean;
12
+ once?(event: "drain", listener: () => void): unknown;
13
+ }
14
+
15
+ export class CliUsageError extends Error {
16
+ constructor(
17
+ message: string,
18
+ readonly scope: CliCommandScope,
19
+ ) {
20
+ super(message);
21
+ this.name = "CliUsageError";
22
+ }
23
+ }
24
+
25
+ export function renderUsageError(error: CliUsageError): string {
26
+ const hint =
27
+ error.scope === "run"
28
+ ? 'Run "tinker run --help" for usage.'
29
+ : 'Run "tinker --help" for usage.';
30
+ return `error: ${sanitizeDiagnosticDetail(error.message)}\n${hint}\n`;
31
+ }
32
+
33
+ export function renderCliFailure(label: string, error: unknown): string {
34
+ return `${label}: ${sanitizeDiagnosticDetail(errorMessage(error))}\n`;
35
+ }
36
+
37
+ export function sanitizeDiagnosticDetail(detail: string): string {
38
+ const withoutAnsi = detail.replaceAll(ANSI_CSI_PATTERN, "");
39
+ let escaped = "";
40
+ for (const character of withoutAnsi) {
41
+ const codePoint = character.codePointAt(0);
42
+ if (codePoint === undefined) {
43
+ continue;
44
+ }
45
+ if (character === "\n") {
46
+ escaped += "\\n";
47
+ } else if (character === "\r") {
48
+ escaped += "\\r";
49
+ } else if (character === "\t") {
50
+ escaped += "\\t";
51
+ } else if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
52
+ escaped += `\\u${codePoint.toString(16).padStart(4, "0")}`;
53
+ } else {
54
+ escaped += character;
55
+ }
56
+ }
57
+ return truncateUtf8(escaped, MAX_CLI_DIAGNOSTIC_DETAIL_BYTES);
58
+ }
59
+
60
+ export function formatDiagnosticPath(filePath: string): string {
61
+ return sanitizeDiagnosticDetail(JSON.stringify(filePath));
62
+ }
63
+
64
+ export async function writeCliOutput(
65
+ writer: CliOutputWriter,
66
+ output: string,
67
+ ): Promise<void> {
68
+ if (output === "") {
69
+ return;
70
+ }
71
+ const accepted = writer.write(output);
72
+ if (accepted === false) {
73
+ await waitForDrain(writer);
74
+ }
75
+ }
76
+
77
+ export async function flushCliOutput(writer: CliOutputWriter): Promise<void> {
78
+ if (writer.writableNeedDrain === true) {
79
+ await waitForDrain(writer);
80
+ }
81
+ }
82
+
83
+ function truncateUtf8(value: string, maxBytes: number): string {
84
+ if (Buffer.byteLength(value) <= maxBytes) {
85
+ return value;
86
+ }
87
+
88
+ const contentLimit = maxBytes - Buffer.byteLength(TRUNCATION_MARKER);
89
+ let result = "";
90
+ let byteLength = 0;
91
+ for (const character of value) {
92
+ const characterBytes = Buffer.byteLength(character);
93
+ if (byteLength + characterBytes > contentLimit) {
94
+ break;
95
+ }
96
+ result += character;
97
+ byteLength += characterBytes;
98
+ }
99
+ return result + TRUNCATION_MARKER;
100
+ }
101
+
102
+ function waitForDrain(writer: CliOutputWriter): Promise<void> {
103
+ if (writer.once === undefined) {
104
+ return Promise.resolve();
105
+ }
106
+ return new Promise((resolve) => {
107
+ writer.once?.("drain", resolve);
108
+ });
109
+ }
110
+
111
+ function errorMessage(error: unknown): string {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export type PackageMetadata = {
4
+ readonly name: string;
5
+ readonly version: string;
6
+ };
7
+
8
+ export class PackageMetadataError extends Error {
9
+ constructor() {
10
+ super("Tinker package metadata is unavailable.");
11
+ this.name = "PackageMetadataError";
12
+ }
13
+ }
14
+
15
+ export async function loadPackageMetadata(
16
+ packageJsonUrl = new URL("../../package.json", import.meta.url),
17
+ ): Promise<PackageMetadata> {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(packageJsonUrl, "utf8")) as unknown;
20
+ if (
21
+ typeof parsed !== "object" ||
22
+ parsed === null ||
23
+ !("name" in parsed) ||
24
+ typeof parsed.name !== "string" ||
25
+ parsed.name.trim() === "" ||
26
+ !("version" in parsed) ||
27
+ typeof parsed.version !== "string" ||
28
+ parsed.version.trim() === ""
29
+ ) {
30
+ throw new TypeError("Invalid package metadata.");
31
+ }
32
+ return Object.freeze({ name: parsed.name, version: parsed.version });
33
+ } catch {
34
+ throw new PackageMetadataError();
35
+ }
36
+ }