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.
- package/CHANGELOG.md +26 -1
- package/README.md +217 -72
- package/bin/tinker.js +75 -25
- package/package.json +8 -3
- package/src/agent/loop.ts +115 -11
- 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/events/observation-text-log.ts +2 -0
- package/src/events/stdout-event-printer.ts +7 -1
- package/src/events/types.ts +27 -3
- package/src/mcp/mcp-manager.ts +2 -19
- package/src/mcp/mcp-tool-executor.ts +3 -4
- package/src/model/model-client.ts +29 -0
- package/src/model/model-context-profile.ts +0 -30
- package/src/model/openai-chat-mapping.ts +54 -15
- package/src/model/openai-chat-model-client.ts +46 -27
- package/src/model/openai-chat-stream.ts +6 -2
- 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/event-store.ts +24 -7
- package/src/tui/slash-commands.ts +76 -24
- package/src/tui/workspace-file-search.ts +78 -71
package/src/agent/loop.ts
CHANGED
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
materializeModelRequest,
|
|
3
3
|
type MaterializedModelRequest,
|
|
4
4
|
type ModelClient,
|
|
5
|
+
type ModelRequestOutput,
|
|
5
6
|
type PreparedModelRequest,
|
|
7
|
+
ProviderResponseError,
|
|
6
8
|
} from "../model/model-client";
|
|
7
9
|
import type { ImageAssetStore } from "../image/image-asset-store";
|
|
8
10
|
import {
|
|
@@ -83,6 +85,8 @@ export class FatalAgentTurnError extends Error {
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
const MODEL_REQUEST_MAX_ATTEMPTS = 2 as const;
|
|
89
|
+
|
|
86
90
|
export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
87
91
|
let lastIteration: IterationIdentity | undefined;
|
|
88
92
|
const committedPrefixAuditor =
|
|
@@ -202,21 +206,15 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
202
206
|
await input.runtimeSession.append({
|
|
203
207
|
type: "model.request.started",
|
|
204
208
|
...iteration,
|
|
205
|
-
data: {
|
|
209
|
+
data: {
|
|
210
|
+
attemptNumber: 1,
|
|
211
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
212
|
+
},
|
|
206
213
|
});
|
|
207
214
|
|
|
208
|
-
let modelOutput;
|
|
209
215
|
try {
|
|
210
216
|
throwIfTurnCancelled(input.signal);
|
|
211
217
|
input.runtimeSession.prepareModelDispatch?.({ iteration, built });
|
|
212
|
-
modelOutput = await input.model.request(request, {
|
|
213
|
-
signal: input.signal,
|
|
214
|
-
identity: {
|
|
215
|
-
iteration,
|
|
216
|
-
runtimeSession: input.runtimeSession,
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
throwIfTurnCancelled(input.signal);
|
|
220
218
|
} catch (error) {
|
|
221
219
|
if (input.signal.aborted) {
|
|
222
220
|
return cancelledResult(
|
|
@@ -228,6 +226,82 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
228
226
|
return failedResult(error, iteration);
|
|
229
227
|
}
|
|
230
228
|
|
|
229
|
+
const requestOptions = {
|
|
230
|
+
signal: input.signal,
|
|
231
|
+
identity: {
|
|
232
|
+
iteration,
|
|
233
|
+
runtimeSession: input.runtimeSession,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
let modelOutput: ModelRequestOutput | undefined;
|
|
237
|
+
let successfulAttempt: 1 | 2 | undefined;
|
|
238
|
+
for (const attemptNumber of [1, 2] as const) {
|
|
239
|
+
if (input.signal.aborted) {
|
|
240
|
+
return cancelledResult(
|
|
241
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
242
|
+
iteration,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (attemptNumber === 2) {
|
|
246
|
+
await input.runtimeSession.append({
|
|
247
|
+
type: "model.request.started",
|
|
248
|
+
...iteration,
|
|
249
|
+
data: {
|
|
250
|
+
attemptNumber,
|
|
251
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
throwIfTurnCancelled(input.signal);
|
|
258
|
+
modelOutput = await input.model.request(request, requestOptions);
|
|
259
|
+
throwIfTurnCancelled(input.signal);
|
|
260
|
+
successfulAttempt = attemptNumber;
|
|
261
|
+
break;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (input.signal.aborted) {
|
|
264
|
+
return cancelledResult(
|
|
265
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
266
|
+
iteration,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const reasoningOnly = isReasoningOnlyProviderError(error);
|
|
271
|
+
const retryDisposition = reasoningOnly
|
|
272
|
+
? attemptNumber === 1
|
|
273
|
+
? "scheduled"
|
|
274
|
+
: "exhausted"
|
|
275
|
+
: "not_retryable";
|
|
276
|
+
await input.runtimeSession.append({
|
|
277
|
+
type: "model.request.failed",
|
|
278
|
+
...iteration,
|
|
279
|
+
data: modelRequestFailureData({
|
|
280
|
+
error,
|
|
281
|
+
request,
|
|
282
|
+
attemptNumber,
|
|
283
|
+
retryDisposition,
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (retryDisposition === "scheduled") {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (retryDisposition === "exhausted") {
|
|
291
|
+
return failedResult(
|
|
292
|
+
new Error(
|
|
293
|
+
`Provider returned reasoning without final text or tool calls in both attempts (provider=${request.provider}, model=${request.model}).`,
|
|
294
|
+
),
|
|
295
|
+
iteration,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return failedResult(error, iteration);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (modelOutput === undefined || successfulAttempt === undefined) {
|
|
302
|
+
throw new Error("Model request attempt state completed without an output.");
|
|
303
|
+
}
|
|
304
|
+
|
|
231
305
|
try {
|
|
232
306
|
input.ledger.appendAssistant({
|
|
233
307
|
iteration,
|
|
@@ -245,7 +319,11 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
245
319
|
await input.runtimeSession.append({
|
|
246
320
|
type: "model.request.finished",
|
|
247
321
|
...iteration,
|
|
248
|
-
data: {
|
|
322
|
+
data: {
|
|
323
|
+
attemptNumber: successfulAttempt,
|
|
324
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
325
|
+
output: modelOutput,
|
|
326
|
+
},
|
|
249
327
|
});
|
|
250
328
|
const measured = input.contextMeter.recordProviderUsage(request, modelOutput);
|
|
251
329
|
await input.runtimeSession.append({
|
|
@@ -617,6 +695,32 @@ function failedResult(
|
|
|
617
695
|
};
|
|
618
696
|
}
|
|
619
697
|
|
|
698
|
+
function isReasoningOnlyProviderError(error: unknown): error is ProviderResponseError {
|
|
699
|
+
return (
|
|
700
|
+
error instanceof ProviderResponseError && error.code === "reasoning_only_assistant"
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function modelRequestFailureData(input: {
|
|
705
|
+
error: unknown;
|
|
706
|
+
request: PreparedModelRequest;
|
|
707
|
+
attemptNumber: 1 | 2;
|
|
708
|
+
retryDisposition: "scheduled" | "not_retryable" | "exhausted";
|
|
709
|
+
}) {
|
|
710
|
+
const providerError =
|
|
711
|
+
input.error instanceof ProviderResponseError ? input.error : undefined;
|
|
712
|
+
return {
|
|
713
|
+
attemptNumber: input.attemptNumber,
|
|
714
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
715
|
+
code: providerError?.code ?? ("provider_request_error" as const),
|
|
716
|
+
retryDisposition: input.retryDisposition,
|
|
717
|
+
provider: input.request.provider,
|
|
718
|
+
model: input.request.model,
|
|
719
|
+
error: errorMessage(input.error),
|
|
720
|
+
...(providerError === undefined ? {} : { diagnostics: providerError.diagnostics }),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
620
724
|
function errorMessage(error: unknown): string {
|
|
621
725
|
return error instanceof Error ? error.message : String(error);
|
|
622
726
|
}
|
|
@@ -92,6 +92,7 @@ import { TurnCancelledError } from "./turn-cancellation";
|
|
|
92
92
|
import type { ToolCompletionInput } from "../context/protocol-frame";
|
|
93
93
|
import type { BuiltContextRequest } from "../context/context-revision";
|
|
94
94
|
import type { CommittedToolCompletion } from "./session-ledger";
|
|
95
|
+
import type { PublicToolingConfig } from "../cli/public-config-contract";
|
|
95
96
|
import type {
|
|
96
97
|
IterationIdentity,
|
|
97
98
|
RunAgentResult,
|
|
@@ -231,6 +232,7 @@ type CommonRuntimeSessionInput = {
|
|
|
231
232
|
observationLogPath?: string;
|
|
232
233
|
};
|
|
233
234
|
webFetchRefiner?: Refiner;
|
|
235
|
+
toolingConfig?: PublicToolingConfig;
|
|
234
236
|
};
|
|
235
237
|
|
|
236
238
|
type CreateNewRuntimeSessionInput = CommonRuntimeSessionInput & {
|
|
@@ -531,6 +533,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
531
533
|
runtimeSession: session.context,
|
|
532
534
|
historyReader: store.historyReader(),
|
|
533
535
|
webFetchRefiner: input.webFetchRefiner,
|
|
536
|
+
toolingConfig: input.toolingConfig,
|
|
534
537
|
...(session.skillCatalog.skills.size === 0
|
|
535
538
|
? {}
|
|
536
539
|
: {
|
|
@@ -544,6 +547,8 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
544
547
|
session.mcpManager = await dependencies.createMcpManager({
|
|
545
548
|
config: mcpConfig,
|
|
546
549
|
runtimeSession: session.context,
|
|
550
|
+
timeoutMs: input.toolingConfig?.mcpTimeoutMs,
|
|
551
|
+
maxObservationChars: input.toolingConfig?.mcpMaxObservationChars,
|
|
547
552
|
});
|
|
548
553
|
for (const executor of session.mcpManager.executors) {
|
|
549
554
|
session.tooling.registry.register(executor, "MCP");
|
|
@@ -870,7 +875,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
870
875
|
return this.input.modelClient.inputModalities?.includes("image") === true;
|
|
871
876
|
}
|
|
872
877
|
|
|
873
|
-
importImage(
|
|
878
|
+
async importImage(
|
|
874
879
|
sourcePath: string,
|
|
875
880
|
signal: AbortSignal,
|
|
876
881
|
prospectiveMessageImageCount: number,
|
|
@@ -878,9 +883,6 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
878
883
|
if (this.state !== "ready") {
|
|
879
884
|
throw new Error(`Cannot import an image while RuntimeSession is ${this.state}.`);
|
|
880
885
|
}
|
|
881
|
-
if (!this.supportsImageInput()) {
|
|
882
|
-
throw new Error("Current model profile does not support image input.");
|
|
883
|
-
}
|
|
884
886
|
if (
|
|
885
887
|
!Number.isSafeInteger(prospectiveMessageImageCount) ||
|
|
886
888
|
prospectiveMessageImageCount < 1 ||
|
|
@@ -888,18 +890,35 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
888
890
|
) {
|
|
889
891
|
throw new Error("Prospective Prompt image count is invalid.");
|
|
890
892
|
}
|
|
891
|
-
const
|
|
892
|
-
this.
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
893
|
+
const assertImageAllowed = () => {
|
|
894
|
+
if (this.state !== "ready") {
|
|
895
|
+
throw new Error(
|
|
896
|
+
`Cannot import an image while RuntimeSession is ${this.state}.`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (!this.supportsImageInput()) {
|
|
900
|
+
throw new Error("Current model profile does not support image input.");
|
|
901
|
+
}
|
|
902
|
+
const activeImageCount = this.input.modelClient.prepare(
|
|
903
|
+
this.requireLedger().buildCommittedModelRequest(
|
|
904
|
+
this.requireTooling().registry.definitions(),
|
|
905
|
+
).request,
|
|
906
|
+
).mediaOccurrenceCount;
|
|
907
|
+
const aggregateImageCount = activeImageCount + prospectiveMessageImageCount;
|
|
908
|
+
if (aggregateImageCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
|
|
909
|
+
throw new ModelRequestMediaAggregateError(
|
|
910
|
+
`Model request would have ${aggregateImageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
if (this.supportsImageInput()) {
|
|
916
|
+
assertImageAllowed();
|
|
901
917
|
}
|
|
902
|
-
return this.assetStore.importWorkspaceFile(sourcePath, {
|
|
918
|
+
return this.assetStore.importWorkspaceFile(sourcePath, {
|
|
919
|
+
signal,
|
|
920
|
+
accept: assertImageAllowed,
|
|
921
|
+
});
|
|
903
922
|
}
|
|
904
923
|
|
|
905
924
|
async verifyImageAssets(
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Command, CommanderError } from "commander";
|
|
2
|
+
import { PUBLIC_CLI_CONTRACT } from "./public-cli-contract";
|
|
3
|
+
import type { PromptSource } from "./prompt-source";
|
|
4
|
+
import { CliUsageError, type CliCommandScope } from "./output";
|
|
5
|
+
|
|
6
|
+
export type CliCommand =
|
|
7
|
+
| { readonly type: "tui"; readonly profileName?: string }
|
|
8
|
+
| {
|
|
9
|
+
readonly type: "run";
|
|
10
|
+
readonly profileName?: string;
|
|
11
|
+
readonly promptSource: PromptSource;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CommandLineResult =
|
|
15
|
+
| { readonly type: "command"; readonly command: CliCommand }
|
|
16
|
+
| {
|
|
17
|
+
readonly type: "terminal";
|
|
18
|
+
readonly stdout: string;
|
|
19
|
+
readonly stderr: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const SUCCESSFUL_TERMINAL_CODES = new Set([
|
|
23
|
+
"commander.help",
|
|
24
|
+
"commander.helpDisplayed",
|
|
25
|
+
"commander.version",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export async function parseCommandLine(
|
|
29
|
+
args: readonly string[],
|
|
30
|
+
packageVersion: string,
|
|
31
|
+
): Promise<CommandLineResult> {
|
|
32
|
+
const scopeHint = preflightArgv(args);
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
let selectedCommand: CliCommand | undefined;
|
|
36
|
+
const contract = PUBLIC_CLI_CONTRACT;
|
|
37
|
+
|
|
38
|
+
const program = new Command()
|
|
39
|
+
.name(contract.name)
|
|
40
|
+
.description(contract.description)
|
|
41
|
+
.helpOption(contract.helpFlags)
|
|
42
|
+
.version(packageVersion, contract.versionFlags)
|
|
43
|
+
.option(contract.tui.profileOption.flags, contract.tui.profileOption.description)
|
|
44
|
+
.helpCommand(contract.helpCommand.command, contract.helpCommand.description)
|
|
45
|
+
.showHelpAfterError('Run "tinker --help" for usage.')
|
|
46
|
+
.showSuggestionAfterError(false)
|
|
47
|
+
.allowExcessArguments(false)
|
|
48
|
+
.enablePositionalOptions()
|
|
49
|
+
.exitOverride()
|
|
50
|
+
.configureOutput({
|
|
51
|
+
writeOut: (value) => {
|
|
52
|
+
stdout += value;
|
|
53
|
+
},
|
|
54
|
+
writeErr: (value) => {
|
|
55
|
+
stderr += value;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
program.action(() => {
|
|
60
|
+
const { profile } = program.opts<{ profile?: string }>();
|
|
61
|
+
selectedCommand = Object.freeze({
|
|
62
|
+
type: "tui",
|
|
63
|
+
...(profile === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { profileName: validateProfile(profile, "root") }),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command(contract.run.command)
|
|
71
|
+
.description(contract.run.description)
|
|
72
|
+
.option(contract.run.profileOption.flags, contract.run.profileOption.description)
|
|
73
|
+
.option(contract.run.stdinOption.flags, contract.run.stdinOption.description)
|
|
74
|
+
.option(contract.run.fileOption.flags, contract.run.fileOption.description)
|
|
75
|
+
.addHelpText("after", `\n${contract.run.helpAfter}\n`)
|
|
76
|
+
.showHelpAfterError('Run "tinker run --help" for usage.')
|
|
77
|
+
.showSuggestionAfterError(false)
|
|
78
|
+
.allowExcessArguments(false)
|
|
79
|
+
.exitOverride()
|
|
80
|
+
.action(
|
|
81
|
+
(
|
|
82
|
+
prompt: string | undefined,
|
|
83
|
+
options: { profile?: string; stdin?: boolean; file?: string },
|
|
84
|
+
) => {
|
|
85
|
+
const sources: PromptSource[] = [];
|
|
86
|
+
if (prompt !== undefined) {
|
|
87
|
+
sources.push({ kind: "argument", value: prompt });
|
|
88
|
+
}
|
|
89
|
+
if (options.stdin === true) {
|
|
90
|
+
sources.push({ kind: "stdin" });
|
|
91
|
+
}
|
|
92
|
+
if (options.file !== undefined) {
|
|
93
|
+
if (options.file.length === 0) {
|
|
94
|
+
throw new CliUsageError("--file requires a non-empty path.", "run");
|
|
95
|
+
}
|
|
96
|
+
sources.push({ kind: "file", filePath: options.file });
|
|
97
|
+
}
|
|
98
|
+
if (sources.length === 0) {
|
|
99
|
+
throw new CliUsageError(
|
|
100
|
+
"Exactly one prompt source is required: [prompt], --stdin, or --file <path>.",
|
|
101
|
+
"run",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (sources.length > 1) {
|
|
105
|
+
throw new CliUsageError(
|
|
106
|
+
"Prompt sources are mutually exclusive: use [prompt], --stdin, or --file <path>.",
|
|
107
|
+
"run",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const promptSource = sources[0];
|
|
111
|
+
if (promptSource === undefined) {
|
|
112
|
+
throw new CliUsageError("A prompt source is required.", "run");
|
|
113
|
+
}
|
|
114
|
+
selectedCommand = Object.freeze({
|
|
115
|
+
type: "run",
|
|
116
|
+
...(options.profile === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { profileName: validateProfile(options.profile, "run") }),
|
|
119
|
+
promptSource,
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await program.parseAsync([...args], { from: "user" });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof CliUsageError) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
if (error instanceof CommanderError) {
|
|
131
|
+
if (SUCCESSFUL_TERMINAL_CODES.has(error.code)) {
|
|
132
|
+
return Object.freeze({ type: "terminal", stdout, stderr });
|
|
133
|
+
}
|
|
134
|
+
throw new CliUsageError(commanderErrorDetail(error), scopeHint);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (selectedCommand === undefined) {
|
|
140
|
+
throw new Error("Commander completed without selecting a command.");
|
|
141
|
+
}
|
|
142
|
+
const topLevelProfile = program.opts<{ profile?: string }>().profile;
|
|
143
|
+
if (selectedCommand.type === "run" && topLevelProfile !== undefined) {
|
|
144
|
+
throw new CliUsageError(
|
|
145
|
+
"The top-level --profile option only applies to the TUI; place --profile after run.",
|
|
146
|
+
"run",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return Object.freeze({ type: "command", command: selectedCommand });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function preflightArgv(args: readonly string[]): CliCommandScope {
|
|
153
|
+
let scope: CliCommandScope = "root";
|
|
154
|
+
let rootBlocked = false;
|
|
155
|
+
let topProfileOccurrences = 0;
|
|
156
|
+
let runProfileOccurrences = 0;
|
|
157
|
+
let stdinOccurrences = 0;
|
|
158
|
+
let fileOccurrences = 0;
|
|
159
|
+
|
|
160
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
161
|
+
const token = args[index];
|
|
162
|
+
if (token === undefined || token === "--") {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
(!rootBlocked && scope === "root" && isRootTerminalOption(token)) ||
|
|
168
|
+
(scope === "run" && isRunTerminalOption(token))
|
|
169
|
+
) {
|
|
170
|
+
return scope;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (scope === "root" && !rootBlocked) {
|
|
174
|
+
const profile = readOptionOccurrence(
|
|
175
|
+
args,
|
|
176
|
+
index,
|
|
177
|
+
token,
|
|
178
|
+
"--profile",
|
|
179
|
+
"root",
|
|
180
|
+
"-p",
|
|
181
|
+
);
|
|
182
|
+
if (profile !== undefined) {
|
|
183
|
+
topProfileOccurrences += 1;
|
|
184
|
+
assertSingleOccurrence("--profile", topProfileOccurrences, "root");
|
|
185
|
+
validateProfile(profile.value, "root");
|
|
186
|
+
index += profile.consumedNext ? 1 : 0;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (token.startsWith("-")) {
|
|
190
|
+
rootBlocked = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (token === "run") {
|
|
194
|
+
scope = "run";
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (token === "help") {
|
|
198
|
+
return "root";
|
|
199
|
+
}
|
|
200
|
+
throw new CliUsageError(`unknown command '${token}'`, "root");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (scope !== "run") {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const profile = readOptionOccurrence(args, index, token, "--profile", "run", "-p");
|
|
207
|
+
if (profile !== undefined) {
|
|
208
|
+
runProfileOccurrences += 1;
|
|
209
|
+
assertSingleOccurrence("--profile", runProfileOccurrences, "run");
|
|
210
|
+
validateProfile(profile.value, "run");
|
|
211
|
+
index += profile.consumedNext ? 1 : 0;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const file = readOptionOccurrence(args, index, token, "--file", "run");
|
|
215
|
+
if (file !== undefined) {
|
|
216
|
+
fileOccurrences += 1;
|
|
217
|
+
assertSingleOccurrence("--file", fileOccurrences, "run");
|
|
218
|
+
if (file.value.length === 0) {
|
|
219
|
+
throw new CliUsageError("--file requires a non-empty path.", "run");
|
|
220
|
+
}
|
|
221
|
+
index += file.consumedNext ? 1 : 0;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (token === "--stdin") {
|
|
225
|
+
stdinOccurrences += 1;
|
|
226
|
+
assertSingleOccurrence("--stdin", stdinOccurrences, "run");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return scope;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readOptionOccurrence(
|
|
233
|
+
args: readonly string[],
|
|
234
|
+
index: number,
|
|
235
|
+
token: string,
|
|
236
|
+
longName: string,
|
|
237
|
+
scope: CliCommandScope,
|
|
238
|
+
shortName?: string,
|
|
239
|
+
): { readonly value: string; readonly consumedNext: boolean } | undefined {
|
|
240
|
+
if (token === longName || token === shortName) {
|
|
241
|
+
const next = args[index + 1];
|
|
242
|
+
if (next === undefined || next.startsWith("-")) {
|
|
243
|
+
throw new CliUsageError(`option '${token}' argument missing`, scope);
|
|
244
|
+
}
|
|
245
|
+
return { value: next, consumedNext: true };
|
|
246
|
+
}
|
|
247
|
+
const longPrefix = `${longName}=`;
|
|
248
|
+
if (token.startsWith(longPrefix)) {
|
|
249
|
+
return { value: token.slice(longPrefix.length), consumedNext: false };
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
shortName !== undefined &&
|
|
253
|
+
token.startsWith(shortName) &&
|
|
254
|
+
token !== shortName &&
|
|
255
|
+
!token.startsWith("--")
|
|
256
|
+
) {
|
|
257
|
+
return { value: token.slice(shortName.length), consumedNext: false };
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateProfile(value: string, scope: CliCommandScope): string {
|
|
263
|
+
if (value.trim() === "") {
|
|
264
|
+
throw new CliUsageError("--profile requires a non-empty value.", scope);
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function assertSingleOccurrence(
|
|
270
|
+
option: string,
|
|
271
|
+
count: number,
|
|
272
|
+
scope: CliCommandScope,
|
|
273
|
+
): void {
|
|
274
|
+
if (count > 1) {
|
|
275
|
+
throw new CliUsageError(`${option} may only be specified once.`, scope);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isRootTerminalOption(token: string): boolean {
|
|
280
|
+
return (
|
|
281
|
+
token === "--help" || token === "-h" || token === "--version" || token === "-V"
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isRunTerminalOption(token: string): boolean {
|
|
286
|
+
return token === "--help" || token === "-h";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function commanderErrorDetail(error: CommanderError): string {
|
|
290
|
+
return error.message.replace(/^error:\s*/u, "");
|
|
291
|
+
}
|