@sayknow-cli/coding-agent 0.5.20 → 0.5.22
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 +22 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +24 -0
- package/dist/types/cli/setup-cli.d.ts +15 -1
- package/dist/types/commands/auth-gateway.d.ts +2 -1
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/decisions/index.d.ts +18 -0
- package/dist/types/decisions/llm-backend.d.ts +51 -0
- package/dist/types/decisions/skill-routing.d.ts +8 -0
- package/dist/types/decisions/types.d.ts +91 -0
- package/dist/types/decisions/typesafe-backend.d.ts +15 -0
- package/dist/types/hooks/skill-state.d.ts +6 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/components/typesafe-key-prompt.d.ts +23 -0
- package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +0 -9
- package/dist/types/setup/decision-provider.d.ts +24 -0
- package/package.json +7 -7
- package/scripts/eval-skill-routing.ts +172 -0
- package/src/cli/auth-gateway-cli.ts +128 -85
- package/src/cli/setup-cli.ts +53 -1
- package/src/commands/auth-gateway.ts +7 -5
- package/src/commands/setup.ts +5 -0
- package/src/config/settings-schema.ts +12 -0
- package/src/decisions/index.ts +84 -0
- package/src/decisions/llm-backend.ts +356 -0
- package/src/decisions/skill-routing.ts +83 -0
- package/src/decisions/types.ts +119 -0
- package/src/decisions/typesafe-backend.ts +168 -0
- package/src/hooks/skill-keywords.ts +56 -0
- package/src/hooks/skill-state.ts +18 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/provider-onboarding-selector.ts +13 -1
- package/src/modes/components/typesafe-key-prompt.ts +108 -0
- package/src/modes/controllers/selector-controller.ts +44 -0
- package/src/sdk/bus/native-runtime-compatibility.ts +30 -3
- package/src/session/agent-session.ts +51 -1
- package/src/setup/decision-provider.ts +94 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key entry for TypeSafe, reachable from the same place models are added.
|
|
3
|
+
*
|
|
4
|
+
* TypeSafe is not a chat model, so it never appears in the model picker — but the place
|
|
5
|
+
* users look when they want to "add a model with a key" is this onboarding list, and
|
|
6
|
+
* making them find a CLI subcommand instead would mean most users never enable it.
|
|
7
|
+
*
|
|
8
|
+
* The key is taken through {@link SecretInput} and consumed once: it is never rendered,
|
|
9
|
+
* never placed in a flag, and never written anywhere but the credential store.
|
|
10
|
+
*/
|
|
11
|
+
import { Container, type Input, matchesKey, SecretInput, Spacer, Text, TruncatedText } from "@sayknow-cli/tui";
|
|
12
|
+
import { theme } from "../theme/theme";
|
|
13
|
+
import { matchesSelectCancel } from "../utils/keybinding-matchers";
|
|
14
|
+
import { DynamicBorder } from "./dynamic-border";
|
|
15
|
+
|
|
16
|
+
export interface TypeSafeKeyPromptResult {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TypeSafeKeyPromptComponent extends Container {
|
|
21
|
+
#content: Container;
|
|
22
|
+
#input: SecretInput | null = null;
|
|
23
|
+
#onSubmit: (result: TypeSafeKeyPromptResult) => void;
|
|
24
|
+
#onCancel: () => void;
|
|
25
|
+
#onRender: () => void;
|
|
26
|
+
#busy = false;
|
|
27
|
+
#error: string | null = null;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
onSubmit: (result: TypeSafeKeyPromptResult) => void,
|
|
31
|
+
onCancel: () => void,
|
|
32
|
+
onRender: () => void = () => {},
|
|
33
|
+
) {
|
|
34
|
+
super();
|
|
35
|
+
this.#onSubmit = onSubmit;
|
|
36
|
+
this.#onCancel = onCancel;
|
|
37
|
+
this.#onRender = onRender;
|
|
38
|
+
this.#content = new Container();
|
|
39
|
+
this.addChild(new DynamicBorder());
|
|
40
|
+
this.addChild(new Spacer(1));
|
|
41
|
+
this.addChild(new TruncatedText(theme.bold("TypeSafe (typed decisions)")));
|
|
42
|
+
this.addChild(
|
|
43
|
+
new TruncatedText(
|
|
44
|
+
theme.fg(
|
|
45
|
+
"muted",
|
|
46
|
+
" Routes workflow decisions through the hosted System One model instead of your chat model.",
|
|
47
|
+
),
|
|
48
|
+
0,
|
|
49
|
+
0,
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
this.addChild(
|
|
53
|
+
new TruncatedText(theme.fg("muted", " Without a key this stays off and nothing else changes."), 0, 0),
|
|
54
|
+
);
|
|
55
|
+
this.addChild(new Spacer(1));
|
|
56
|
+
this.addChild(this.#content);
|
|
57
|
+
this.addChild(new Spacer(1));
|
|
58
|
+
this.addChild(new DynamicBorder());
|
|
59
|
+
this.#render();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shown while the key is being checked against the live API. */
|
|
63
|
+
setBusy(busy: boolean): void {
|
|
64
|
+
this.#busy = busy;
|
|
65
|
+
this.#render();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keeps the prompt open so a rejected key can be corrected without restarting. */
|
|
69
|
+
setError(message: string): void {
|
|
70
|
+
this.#busy = false;
|
|
71
|
+
this.#error = message;
|
|
72
|
+
this.#render();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#render(): void {
|
|
76
|
+
this.#content.clear();
|
|
77
|
+
if (this.#busy) {
|
|
78
|
+
this.#input = null;
|
|
79
|
+
this.#content.addChild(new Text(theme.fg("muted", "Verifying key against TypeSafe…"), 0, 0));
|
|
80
|
+
this.#onRender();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (this.#error) {
|
|
84
|
+
this.#content.addChild(new Text(theme.fg("error", this.#error), 0, 0));
|
|
85
|
+
this.#content.addChild(new Spacer(1));
|
|
86
|
+
}
|
|
87
|
+
this.#content.addChild(new Text("Paste your TypeSafe API key:", 0, 0));
|
|
88
|
+
this.#content.addChild(new Spacer(1));
|
|
89
|
+
const input = new SecretInput();
|
|
90
|
+
input.onSubmit = secret => {
|
|
91
|
+
const apiKey = secret.consume().trim();
|
|
92
|
+
if (!apiKey) return;
|
|
93
|
+
this.#error = null;
|
|
94
|
+
this.#onSubmit({ apiKey });
|
|
95
|
+
};
|
|
96
|
+
this.#input = input;
|
|
97
|
+
this.#content.addChild(input);
|
|
98
|
+
this.#onRender();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleInput(keyData: string): void {
|
|
102
|
+
if (matchesSelectCancel(keyData) || matchesKey(keyData, "escape")) {
|
|
103
|
+
this.#onCancel();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
(this.#input as Input | SecretInput | null)?.handleInput(keyData);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -150,6 +150,7 @@ import { ToolExecutionComponent } from "../components/tool-execution";
|
|
|
150
150
|
import type { StatusLineSettings } from "../components/tool-status-header";
|
|
151
151
|
import { TranscriptViewerOverlay, transcriptViewerEntries } from "../components/transcript-viewer-overlay";
|
|
152
152
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
153
|
+
import { TypeSafeKeyPromptComponent } from "../components/typesafe-key-prompt";
|
|
153
154
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
154
155
|
import type { JobsObserver } from "../jobs-observer";
|
|
155
156
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
@@ -879,6 +880,8 @@ export class SelectorController {
|
|
|
879
880
|
void this.showOAuthSelector("login");
|
|
880
881
|
} else if (action === "import-credentials") {
|
|
881
882
|
void this.#handleCredentialImport();
|
|
883
|
+
} else if (action === "typesafe-key") {
|
|
884
|
+
this.#showTypeSafeKeyPrompt();
|
|
882
885
|
} else {
|
|
883
886
|
this.ctx.showStatus(formatProviderOnboardingCommandGuide());
|
|
884
887
|
}
|
|
@@ -892,6 +895,47 @@ export class SelectorController {
|
|
|
892
895
|
});
|
|
893
896
|
}
|
|
894
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Take a TypeSafe key and verify it before storing.
|
|
900
|
+
*
|
|
901
|
+
* Verification is not optional here. The decision service fails open by design, so an
|
|
902
|
+
* unverified bad key produces no error anywhere: decisions silently keep coming from
|
|
903
|
+
* the user's own model while the UI claims TypeSafe is on. Better to keep the prompt
|
|
904
|
+
* open and say the key was rejected.
|
|
905
|
+
*/
|
|
906
|
+
#showTypeSafeKeyPrompt(): void {
|
|
907
|
+
this.showSelector(done => {
|
|
908
|
+
let prompt: TypeSafeKeyPromptComponent | undefined;
|
|
909
|
+
prompt = new TypeSafeKeyPromptComponent(
|
|
910
|
+
({ apiKey }) => {
|
|
911
|
+
prompt?.setBusy(true);
|
|
912
|
+
void (async () => {
|
|
913
|
+
try {
|
|
914
|
+
const { formatTypeSafeKeyResult, setTypeSafeKey } = await import("../../setup/decision-provider");
|
|
915
|
+
const result = await setTypeSafeKey({ apiKey });
|
|
916
|
+
if (result.error) {
|
|
917
|
+
prompt?.setError(result.error);
|
|
918
|
+
this.ctx.ui.requestRender();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
done();
|
|
922
|
+
this.ctx.showStatus(formatTypeSafeKeyResult(result));
|
|
923
|
+
} catch (error) {
|
|
924
|
+
prompt?.setError(error instanceof Error ? error.message : String(error));
|
|
925
|
+
this.ctx.ui.requestRender();
|
|
926
|
+
}
|
|
927
|
+
})();
|
|
928
|
+
},
|
|
929
|
+
() => {
|
|
930
|
+
done();
|
|
931
|
+
this.ctx.ui.requestRender();
|
|
932
|
+
},
|
|
933
|
+
() => this.ctx.ui.requestRender(),
|
|
934
|
+
);
|
|
935
|
+
return { component: prompt, focus: prompt };
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
895
939
|
async #handleCredentialImport(): Promise<void> {
|
|
896
940
|
this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
|
|
897
941
|
const preview = await runExternalCredentialAutoImport({
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
1
3
|
const REQUIRED_WORKFLOW_ARBITRATION_METHODS = ["registerArbitratedAsk", "retireIfUnclaimed", "stopAndWait"] as const;
|
|
2
4
|
|
|
3
5
|
export class NativeRuntimeCompatibilityError extends Error {
|
|
@@ -8,12 +10,20 @@ export class NativeRuntimeCompatibilityError extends Error {
|
|
|
8
10
|
readonly runtimeVersion: string,
|
|
9
11
|
readonly nativeVersion: string,
|
|
10
12
|
readonly workflowArbitrationAvailable: boolean,
|
|
13
|
+
readonly nativeModulePath: string | null = null,
|
|
11
14
|
) {
|
|
15
|
+
const loadedFrom = nativeModulePath ? ` (loaded from ${nativeModulePath})` : "";
|
|
16
|
+
const causes = [
|
|
17
|
+
...(runtimeVersion === nativeVersion
|
|
18
|
+
? []
|
|
19
|
+
: [`loaded native version is ${nativeVersion}${loadedFrom}, expected ${runtimeVersion}`]),
|
|
20
|
+
...(workflowArbitrationAvailable ? [] : [`required workflow arbitration methods are missing${loadedFrom}`]),
|
|
21
|
+
];
|
|
12
22
|
super(
|
|
13
23
|
`Incompatible @sayknow-cli/natives for @sayknow-cli/coding-agent@${runtimeVersion}: ` +
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
24
|
+
`${causes.join("; ")}. ` +
|
|
25
|
+
`Reinstall matching @sayknow-cli/coding-agent and @sayknow-cli/natives packages, ` +
|
|
26
|
+
`and remove any stale nested node_modules copy of @sayknow-cli/natives that shadows it.`,
|
|
17
27
|
);
|
|
18
28
|
this.name = "NativeRuntimeCompatibilityError";
|
|
19
29
|
}
|
|
@@ -27,10 +37,26 @@ function hasWorkflowArbitrationMethods(notificationServer: unknown): boolean {
|
|
|
27
37
|
);
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Where the process actually loaded `@sayknow-cli/natives` from. Diagnostic only:
|
|
42
|
+
* a nested `node_modules` copy wins resolution over a workspace link, so the path
|
|
43
|
+
* is the difference between "reinstall something" and a one-line fix.
|
|
44
|
+
*/
|
|
45
|
+
function resolveNativeModulePath(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const resolved = import.meta.resolve?.("@sayknow-cli/natives");
|
|
48
|
+
if (typeof resolved !== "string") return null;
|
|
49
|
+
return resolved.startsWith("file:") ? fileURLToPath(resolved) : resolved;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export function assertNativeRuntimeCompatibility(input: {
|
|
31
56
|
runtimeVersion: string;
|
|
32
57
|
nativeVersion: string;
|
|
33
58
|
notificationServer: unknown;
|
|
59
|
+
nativeModulePath?: string | null;
|
|
34
60
|
}): void {
|
|
35
61
|
const workflowArbitrationAvailable = hasWorkflowArbitrationMethods(input.notificationServer);
|
|
36
62
|
if (input.runtimeVersion !== input.nativeVersion || !workflowArbitrationAvailable)
|
|
@@ -38,5 +64,6 @@ export function assertNativeRuntimeCompatibility(input: {
|
|
|
38
64
|
input.runtimeVersion,
|
|
39
65
|
input.nativeVersion,
|
|
40
66
|
workflowArbitrationAvailable,
|
|
67
|
+
input.nativeModulePath ?? resolveNativeModulePath(),
|
|
41
68
|
);
|
|
42
69
|
}
|
|
@@ -202,6 +202,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
|
|
|
202
202
|
import type { SettingPath } from "../config/settings-schema";
|
|
203
203
|
import { getDefault } from "../config/settings-schema";
|
|
204
204
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
205
|
+
import { createDecisionService } from "../decisions";
|
|
206
|
+
import { createSemanticSkillRouter, type SkillRouter } from "../decisions/skill-routing";
|
|
205
207
|
import { loadCapability } from "../discovery";
|
|
206
208
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
207
209
|
import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
|
|
@@ -260,7 +262,11 @@ import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slas
|
|
|
260
262
|
import { GoalRuntime } from "../goals/runtime";
|
|
261
263
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
262
264
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
263
|
-
import {
|
|
265
|
+
import {
|
|
266
|
+
buildSkillStopOutput,
|
|
267
|
+
detectPrimarySkillKeyword,
|
|
268
|
+
ensureWorkflowSkillActivationState,
|
|
269
|
+
} from "../hooks/skill-state";
|
|
264
270
|
import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
265
271
|
import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
|
|
266
272
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
@@ -1842,6 +1848,9 @@ export class AgentSession {
|
|
|
1842
1848
|
// Model registry for API key resolution
|
|
1843
1849
|
#modelRegistry: ModelRegistry;
|
|
1844
1850
|
|
|
1851
|
+
/** Built on first use; the decision service resolves model and credential lazily. */
|
|
1852
|
+
#semanticSkillRouter?: SkillRouter;
|
|
1853
|
+
|
|
1845
1854
|
// Tool registry and prompt builder for extensions
|
|
1846
1855
|
#toolRegistry: Map<string, AgentTool>;
|
|
1847
1856
|
#workflowGateToolSession: ToolSession | undefined;
|
|
@@ -7548,6 +7557,41 @@ export class AgentSession {
|
|
|
7548
7557
|
* @throws Error if streaming and no streamingBehavior specified
|
|
7549
7558
|
* @throws Error if no model selected or no API key available (when not streaming)
|
|
7550
7559
|
*/
|
|
7560
|
+
/**
|
|
7561
|
+
* Second-stage workflow routing for prompts the keyword table cannot see.
|
|
7562
|
+
*
|
|
7563
|
+
* Runs in this process, not the hook process: the hook only receives paths and
|
|
7564
|
+
* config, so it has no model registry and no credentials to call anything with.
|
|
7565
|
+
*
|
|
7566
|
+
* Deliberately best-effort — a disabled setting, a missing credential, a timeout or
|
|
7567
|
+
* a nonsense answer all resolve to "no activation", which is precisely the
|
|
7568
|
+
* behaviour before this stage existed.
|
|
7569
|
+
*/
|
|
7570
|
+
async #routeWorkflowSemantically(text: string): Promise<void> {
|
|
7571
|
+
if (!this.settings.get("decisions.enabled")) return;
|
|
7572
|
+
if (detectPrimarySkillKeyword(text)) return; // deterministic stage already decided
|
|
7573
|
+
try {
|
|
7574
|
+
this.#semanticSkillRouter ??= createSemanticSkillRouter(
|
|
7575
|
+
createDecisionService({
|
|
7576
|
+
registry: this.#modelRegistry,
|
|
7577
|
+
settings: this.settings,
|
|
7578
|
+
sessionId: this.sessionManager.getSessionId(),
|
|
7579
|
+
enabled: true,
|
|
7580
|
+
}),
|
|
7581
|
+
);
|
|
7582
|
+
const skill = await this.#semanticSkillRouter(text);
|
|
7583
|
+
if (!skill) return;
|
|
7584
|
+
await ensureWorkflowSkillActivationState({
|
|
7585
|
+
cwd: this.sessionManager.getCwd(),
|
|
7586
|
+
skill,
|
|
7587
|
+
sessionId: this.sessionManager.getSessionId(),
|
|
7588
|
+
});
|
|
7589
|
+
this.#attachAskTool();
|
|
7590
|
+
} catch (error) {
|
|
7591
|
+
logger.debug("agent-session: semantic workflow routing failed", { error: String(error) });
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
7594
|
+
|
|
7551
7595
|
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
|
7552
7596
|
this.#assertRecoveryHydrationPromoted();
|
|
7553
7597
|
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
|
@@ -7606,6 +7650,12 @@ export class AgentSession {
|
|
|
7606
7650
|
const deepInterviewUserIntentEpoch =
|
|
7607
7651
|
claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
|
|
7608
7652
|
|
|
7653
|
+
// The keyword table in `hooks/skill-keywords.ts` is thirteen literal strings, so a
|
|
7654
|
+
// Korean phrasing of "plan this before you touch code" activates nothing. Ask a
|
|
7655
|
+
// cheap model only when the deterministic stage found nothing, and only for real
|
|
7656
|
+
// user turns. Any failure leaves routing to the system prompt, exactly as before.
|
|
7657
|
+
if (claimsGenuineUserIntent && !this.isStreaming) await this.#routeWorkflowSemantically(expandedText);
|
|
7658
|
+
|
|
7609
7659
|
// If streaming, queue via steer() or followUp() based on option
|
|
7610
7660
|
if (this.isStreaming) {
|
|
7611
7661
|
if (!options?.streamingBehavior) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store a TypeSafe key so the typed-decision service can use the hosted model.
|
|
3
|
+
*
|
|
4
|
+
* TypeSafe is **not** a chat provider: no streaming, no messages, no text output. So it
|
|
5
|
+
* must not be written into `models.yml` the way `addApiCompatibleProvider` writes
|
|
6
|
+
* OpenAI-compatible endpoints — that would invent a chat model that cannot answer a
|
|
7
|
+
* chat request and would show up in the model picker as something users could select.
|
|
8
|
+
*
|
|
9
|
+
* Only the credential is stored. The decision backend reads it through the same
|
|
10
|
+
* `getApiKeyForProvider` path every other provider uses, so adding a key turns the
|
|
11
|
+
* backend on and removing it turns the backend off, with nothing else to configure.
|
|
12
|
+
*/
|
|
13
|
+
import { AuthStorage } from "@sayknow-cli/ai";
|
|
14
|
+
import { getAgentDbPath } from "@sayknow-cli/utils";
|
|
15
|
+
import { TYPESAFE_PROVIDER } from "../decisions";
|
|
16
|
+
|
|
17
|
+
const VERIFY_ENDPOINT = "https://api.typesafe.ai/v1/models";
|
|
18
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
export interface TypeSafeKeySetupResult {
|
|
21
|
+
provider: string;
|
|
22
|
+
verified: boolean;
|
|
23
|
+
/** Present when verification ran and failed; the key is not stored in that case. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verify a key against the live API before storing it.
|
|
29
|
+
*
|
|
30
|
+
* Storing an unverified key is worse than refusing it: the decision service fails open,
|
|
31
|
+
* so a bad key produces no error anywhere — it silently falls back to the user's own
|
|
32
|
+
* model forever, and the user believes TypeSafe is active.
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyTypeSafeKey(apiKey: string, fetchImpl: typeof fetch = fetch): Promise<string | null> {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetchImpl(VERIFY_ENDPOINT, {
|
|
39
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
if (response.ok) return null;
|
|
43
|
+
if (response.status === 401 || response.status === 403) return "key rejected by TypeSafe (401/403)";
|
|
44
|
+
return `TypeSafe returned ${response.status}`;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return `could not reach TypeSafe: ${error instanceof Error ? error.message : String(error)}`;
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SetTypeSafeKeyOptions {
|
|
53
|
+
apiKey: string;
|
|
54
|
+
/** Skip the live check. Only for offline setup; the key may be wrong. */
|
|
55
|
+
skipVerify?: boolean;
|
|
56
|
+
fetchImpl?: typeof fetch;
|
|
57
|
+
dbPath?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function setTypeSafeKey(options: SetTypeSafeKeyOptions): Promise<TypeSafeKeySetupResult> {
|
|
61
|
+
const apiKey = options.apiKey.trim();
|
|
62
|
+
if (!apiKey) throw new Error("TypeSafe API key must not be empty");
|
|
63
|
+
|
|
64
|
+
let verified = false;
|
|
65
|
+
if (!options.skipVerify) {
|
|
66
|
+
const failure = await verifyTypeSafeKey(apiKey, options.fetchImpl ?? fetch);
|
|
67
|
+
if (failure) return { provider: TYPESAFE_PROVIDER, verified: false, error: failure };
|
|
68
|
+
verified = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const authStorage = await AuthStorage.create(options.dbPath ?? getAgentDbPath());
|
|
72
|
+
try {
|
|
73
|
+
await authStorage.set(TYPESAFE_PROVIDER, { type: "api_key", key: apiKey });
|
|
74
|
+
} finally {
|
|
75
|
+
authStorage.close();
|
|
76
|
+
}
|
|
77
|
+
return { provider: TYPESAFE_PROVIDER, verified };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function removeTypeSafeKey(dbPath?: string): Promise<void> {
|
|
81
|
+
const authStorage = await AuthStorage.create(dbPath ?? getAgentDbPath());
|
|
82
|
+
try {
|
|
83
|
+
await authStorage.remove(TYPESAFE_PROVIDER);
|
|
84
|
+
} finally {
|
|
85
|
+
authStorage.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatTypeSafeKeyResult(result: TypeSafeKeySetupResult): string {
|
|
90
|
+
if (result.error) return `✗ TypeSafe key not stored: ${result.error}`;
|
|
91
|
+
return result.verified
|
|
92
|
+
? "✓ TypeSafe key verified and stored. Typed decisions will use the hosted model."
|
|
93
|
+
: "✓ TypeSafe key stored (unverified). Typed decisions will try the hosted model.";
|
|
94
|
+
}
|