@sayknow-cli/coding-agent 0.5.21 → 0.5.23

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 (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/cli/setup-cli.d.ts +15 -1
  3. package/dist/types/commands/setup.d.ts +6 -0
  4. package/dist/types/config/settings-schema.d.ts +9 -0
  5. package/dist/types/decisions/index.d.ts +18 -0
  6. package/dist/types/decisions/llm-backend.d.ts +51 -0
  7. package/dist/types/decisions/skill-routing.d.ts +10 -0
  8. package/dist/types/decisions/types.d.ts +91 -0
  9. package/dist/types/decisions/typesafe-backend.d.ts +15 -0
  10. package/dist/types/hooks/skill-state.d.ts +6 -0
  11. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  12. package/dist/types/modes/components/typesafe-key-prompt.d.ts +23 -0
  13. package/dist/types/modes/controllers/selector-controller.d.ts +9 -0
  14. package/dist/types/modes/interactive-mode.d.ts +1 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +3 -1
  17. package/dist/types/session/agent-session.d.ts +0 -9
  18. package/dist/types/setup/decision-provider.d.ts +24 -0
  19. package/dist/types/setup/model-onboarding-guidance.d.ts +5 -0
  20. package/package.json +7 -7
  21. package/scripts/eval-skill-routing.ts +173 -0
  22. package/src/cli/setup-cli.ts +53 -1
  23. package/src/commands/setup.ts +5 -0
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/decisions/index.ts +84 -0
  26. package/src/decisions/llm-backend.ts +356 -0
  27. package/src/decisions/skill-routing.ts +123 -0
  28. package/src/decisions/types.ts +119 -0
  29. package/src/decisions/typesafe-backend.ts +168 -0
  30. package/src/hooks/skill-keywords.ts +56 -0
  31. package/src/hooks/skill-state.ts +18 -2
  32. package/src/modes/components/provider-onboarding-selector.ts +13 -1
  33. package/src/modes/components/typesafe-key-prompt.ts +108 -0
  34. package/src/modes/controllers/selector-controller.ts +44 -0
  35. package/src/modes/interactive-mode.ts +4 -0
  36. package/src/modes/types.ts +2 -0
  37. package/src/prompts/agents/architect.md +1 -1
  38. package/src/prompts/agents/critic.md +1 -1
  39. package/src/prompts/agents/planner.md +1 -1
  40. package/src/sdk/bus/native-runtime-compatibility.ts +30 -3
  41. package/src/session/agent-session.ts +135 -2
  42. package/src/setup/decision-provider.ts +94 -0
  43. package/src/setup/model-onboarding-guidance.ts +7 -1
  44. package/src/slash-commands/builtin-registry.ts +18 -1
@@ -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({
@@ -2133,6 +2133,10 @@ export class InteractiveMode implements InteractiveModeContext {
2133
2133
  this.#selectorController.showEffortSelector();
2134
2134
  }
2135
2135
 
2136
+ showTypeSafeKeyPrompt(): void {
2137
+ this.#selectorController.showTypeSafeKeyPrompt();
2138
+ }
2139
+
2136
2140
  showProviderOnboarding(): void {
2137
2141
  this.#selectorController.showProviderOnboarding();
2138
2142
  }
@@ -321,6 +321,8 @@ export interface InteractiveModeContext {
321
321
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
322
322
  showEffortSelector(): void;
323
323
  showProviderOnboarding(): void;
324
+ /** Open the TypeSafe key prompt (typed decisions; not a chat model). */
325
+ showTypeSafeKeyPrompt(): void;
324
326
  showPluginSelector(mode?: "install" | "uninstall"): void;
325
327
  showUserMessageSelector(): void;
326
328
  showTreeSelector(): void;
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: architect
3
3
  description: Read-only architecture and code-review agent with severity-rated findings and status verdicts
4
- tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, irc
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, skill, irc
5
5
  thinking-level: high
6
6
  blocking: true
7
7
  forkContext: allowed
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: critic
3
3
  description: Read-only plan critic that approves only actionable, verifiable execution plans
4
- tools: read, search, find, lsp, ast_grep, web_search, bash, irc
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, skill, irc
5
5
  thinking-level: high
6
6
  bashAllowedPrefixes:
7
7
  - skc ralplan --write
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: planner
3
3
  description: Read-only planning agent for sequencing, acceptance criteria, risks, and handoff shape
4
- tools: read, search, find, lsp, ast_grep, web_search, bash, irc
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, skill, irc
5
5
  thinking-level: medium
6
6
  bashAllowedPrefixes:
7
7
  - skc ralplan --write
@@ -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
- `loaded native version is ${nativeVersion}, and required workflow arbitration methods are ` +
15
- `${workflowArbitrationAvailable ? "available" : "missing"}. ` +
16
- `Reinstall matching @sayknow-cli/coding-agent and @sayknow-cli/natives packages.`,
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,12 @@ 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 { buildSkillStopOutput, ensureWorkflowSkillActivationState } from "../hooks/skill-state";
265
+ import {
266
+ buildSkillStopOutput,
267
+ detectPrimarySkillKeyword,
268
+ ensureWorkflowSkillActivationState,
269
+ } from "../hooks/skill-state";
270
+ import { buildUiSkillActivationContext } from "../hooks/ui-skill-keywords";
264
271
  import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
265
272
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
266
273
  import { resolveMemoryBackend } from "../memory-backend";
@@ -307,6 +314,7 @@ import {
307
314
  } from "../skc-runtime/session-state-sidecar";
308
315
  import { requestSkcWorkerIntegrationAttempt } from "../skc-runtime/team-runtime";
309
316
  import {
317
+ type CanonicalSkcWorkflowSkill,
310
318
  isCanonicalSkcWorkflowSkill,
311
319
  readVisibleSkillActiveState,
312
320
  syncSkillActiveState,
@@ -1842,6 +1850,9 @@ export class AgentSession {
1842
1850
  // Model registry for API key resolution
1843
1851
  #modelRegistry: ModelRegistry;
1844
1852
 
1853
+ /** Built on first use; the decision service resolves model and credential lazily. */
1854
+ #semanticSkillRouter?: SkillRouter;
1855
+
1845
1856
  // Tool registry and prompt builder for extensions
1846
1857
  #toolRegistry: Map<string, AgentTool>;
1847
1858
  #workflowGateToolSession: ToolSession | undefined;
@@ -7548,6 +7559,78 @@ export class AgentSession {
7548
7559
  * @throws Error if streaming and no streamingBehavior specified
7549
7560
  * @throws Error if no model selected or no API key available (when not streaming)
7550
7561
  */
7562
+ /**
7563
+ * Semantic workflow routing for prompts the keyword table cannot express.
7564
+ *
7565
+ * Runs in this process, not the hook process: the hook only receives paths and
7566
+ * config, so it has no model registry and no credentials to call anything with.
7567
+ *
7568
+ * **The keyword table is not consulted here, and that is deliberate.** An earlier
7569
+ * version returned early on a keyword hit, on the assumption that the deterministic
7570
+ * stage had already activated the workflow. That assumption holds only under the
7571
+ * Codex host, where `skc codex-native-hook` runs on `UserPromptSubmit`. This session
7572
+ * never fires that hook, so the early return meant a prompt containing an enumerated
7573
+ * keyword activated *nothing at all* — strictly worse than before the keywords
7574
+ * existed, because the semantic stage had been handling those phrasings.
7575
+ *
7576
+ * Keywords remain advisory in this host, as they always were: the routing rules in
7577
+ * the system prompt describe them to the model. Only this stage activates, and only
7578
+ * when it is confident enough to be worth the mutation guard and Stop hook that
7579
+ * activation switches on.
7580
+ *
7581
+ * Deliberately best-effort — a disabled setting, a missing credential, a timeout, a
7582
+ * nonsense answer or low confidence all resolve to "no activation", which is
7583
+ * precisely the behaviour before this stage existed.
7584
+ */
7585
+ async #routeWorkflowSemantically(text: string): Promise<void> {
7586
+ // Stage one: the keyword table. Free, deterministic, and measured at zero false
7587
+ // positives, so it is not gated behind the opt-in setting — gating it was why an
7588
+ // enumerated phrase activated nothing in this host while the Codex hook activated
7589
+ // it fine. Activating here makes the two hosts agree.
7590
+ const keyword = detectPrimarySkillKeyword(text);
7591
+ if (keyword) {
7592
+ await this.#activateWorkflowSkill(keyword.skill);
7593
+ return;
7594
+ }
7595
+
7596
+ // Stage two costs a model call, so it stays opt-in.
7597
+ if (!this.settings.get("decisions.enabled")) return;
7598
+ try {
7599
+ this.#semanticSkillRouter ??= createSemanticSkillRouter(
7600
+ createDecisionService({
7601
+ registry: this.#modelRegistry,
7602
+ settings: this.settings,
7603
+ sessionId: this.sessionManager.getSessionId(),
7604
+ enabled: true,
7605
+ }),
7606
+ );
7607
+ const skill = await this.#semanticSkillRouter(text);
7608
+ if (!skill) return;
7609
+ await this.#activateWorkflowSkill(skill);
7610
+ } catch (error) {
7611
+ logger.debug("agent-session: semantic workflow routing failed", { error: String(error) });
7612
+ }
7613
+ }
7614
+
7615
+ /**
7616
+ * Seed workflow state and attach the ask tool.
7617
+ *
7618
+ * Both routing stages funnel through here, and both are best-effort: a failure to
7619
+ * write state must never take down the user's turn, so it is logged and swallowed.
7620
+ */
7621
+ async #activateWorkflowSkill(skill: CanonicalSkcWorkflowSkill): Promise<void> {
7622
+ try {
7623
+ await ensureWorkflowSkillActivationState({
7624
+ cwd: this.sessionManager.getCwd(),
7625
+ skill,
7626
+ sessionId: this.sessionManager.getSessionId(),
7627
+ });
7628
+ this.#attachAskTool();
7629
+ } catch (error) {
7630
+ logger.debug("agent-session: workflow activation failed", { skill, error: String(error) });
7631
+ }
7632
+ }
7633
+
7551
7634
  async prompt(text: string, options?: PromptOptions): Promise<void> {
7552
7635
  this.#assertRecoveryHydrationPromoted();
7553
7636
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
@@ -7606,6 +7689,20 @@ export class AgentSession {
7606
7689
  const deepInterviewUserIntentEpoch =
7607
7690
  claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
7608
7691
 
7692
+ // The keyword table in `hooks/skill-keywords.ts` is a list of literal strings, so a
7693
+ // Korean phrasing of "plan this before you touch code" activates nothing. Ask a
7694
+ // cheap model instead, and only for real user turns. Any failure leaves routing to
7695
+ // the system prompt, exactly as before.
7696
+ //
7697
+ // Ordering matters: this runs *after* the deep-interview intent claim above but
7698
+ // before streaming, so a workflow it activates is in place before the ambiguity
7699
+ // detector in `skc-runtime/deep-interview-ambiguity.ts` would otherwise seed one.
7700
+ // Verified end to end: "설계가 위험해 보여 … 승인받을 문서부터 만들자" seeds
7701
+ // deep-interview with the setting off and ralplan with it on. Both are plausible
7702
+ // readings and ralplan is the better one here, but the point is that enabling this
7703
+ // can *change* an activation rather than only add one where there was none.
7704
+ if (claimsGenuineUserIntent && !this.isStreaming) await this.#routeWorkflowSemantically(expandedText);
7705
+
7609
7706
  // If streaming, queue via steer() or followUp() based on option
7610
7707
  if (this.isStreaming) {
7611
7708
  if (!options?.streamingBehavior) {
@@ -7639,6 +7736,7 @@ export class AgentSession {
7639
7736
  const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
7640
7737
  const eagerTodoPrelude =
7641
7738
  !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
7739
+ const uiSkillPrelude = options?.synthetic ? undefined : this.#createUiSkillPrelude(expandedText);
7642
7740
 
7643
7741
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
7644
7742
  if (options?.images) {
@@ -7667,7 +7765,13 @@ export class AgentSession {
7667
7765
  try {
7668
7766
  await this.#promptWithMessage(message, expandedText, {
7669
7767
  ...options,
7670
- prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
7768
+ prependMessages:
7769
+ eagerTodoPrelude || uiSkillPrelude
7770
+ ? [
7771
+ ...(uiSkillPrelude ? [uiSkillPrelude] : []),
7772
+ ...(eagerTodoPrelude ? [eagerTodoPrelude.message] : []),
7773
+ ]
7774
+ : undefined,
7671
7775
  admissionLease: admission,
7672
7776
  resetRetryReplaySafety: true,
7673
7777
  });
@@ -11914,6 +12018,35 @@ export class AgentSession {
11914
12018
  });
11915
12019
  }
11916
12020
 
12021
+ /**
12022
+ * Deterministic activation for the bundled frontend UI/UX skills.
12023
+ *
12024
+ * `hooks/ui-skill-keywords.ts` already knows how to match a prompt against all
12025
+ * thirteen bundled skills in Korean and English, but the only caller was the Codex
12026
+ * `UserPromptSubmit` hook — which this host never fires. In an SKC session the skills
12027
+ * were therefore advertised solely by a sentence in the system prompt, leaving it to
12028
+ * the model to notice and obey. That is not activation, it is hope.
12029
+ *
12030
+ * The matcher is deliberately conservative and measured that way: on sixteen real
12031
+ * prompts it caught 5 of 8 frontend requests and produced **zero** false positives on
12032
+ * the 8 backend ones. Missing a match costs nothing — the system-prompt sentence is
12033
+ * still there — while a wrong match would load a design skill onto a database task.
12034
+ * That asymmetry is why a reminder is the right shape here and a forced tool call is
12035
+ * not.
12036
+ */
12037
+ #createUiSkillPrelude(promptText: string): AgentMessage | undefined {
12038
+ if (this.#planModeState?.enabled) return undefined;
12039
+ const directive = buildUiSkillActivationContext(promptText);
12040
+ if (!directive) return undefined;
12041
+ logger.debug("agent-session: bundled UI skill matched", { promptChars: promptText.length });
12042
+ return {
12043
+ role: "developer",
12044
+ content: [{ type: "text", text: `<system-reminder>\n${directive}\n</system-reminder>` }],
12045
+ attribution: "agent",
12046
+ timestamp: Date.now(),
12047
+ };
12048
+ }
12049
+
11917
12050
  #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
11918
12051
  const eagerTodosEnabled = this.settings.get("todo.eager");
11919
12052
  const todosEnabled = this.settings.get("todo.enabled");
@@ -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
+ }
@@ -6,6 +6,11 @@ export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset
6
6
 
7
7
  export const MODEL_ONBOARDING_SETUP_COMMAND = "skc setup provider";
8
8
  export const MODEL_ONBOARDING_OAUTH_COMMAND = "/provider login [provider-id] or /login [provider-id]";
9
+ /**
10
+ * TypeSafe is not a chat model and never appears in the model list, so the only way a
11
+ * user learns it exists is from the surfaces where they go to add credentials.
12
+ */
13
+ export const MODEL_ONBOARDING_TYPESAFE_COMMAND = "/provider typesafe";
9
14
 
10
15
  export function formatModelOnboardingGuidance(): string {
11
16
  return [
@@ -15,12 +20,13 @@ export function formatModelOnboardingGuidance(): string {
15
20
  `Provider presets: ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
16
21
  `API-compatible custom providers: ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
17
22
  `OAuth/subscription providers: ${MODEL_ONBOARDING_OAUTH_COMMAND}.`,
23
+ `Typed decisions (not a chat model): ${MODEL_ONBOARDING_TYPESAFE_COMMAND} adds a TypeSafe key.`,
18
24
  "Then run /model to select a configured model or assign it to a target.",
19
25
  ].join("\n");
20
26
  }
21
27
 
22
28
  export function formatModelOnboardingInlineHint(): string {
23
- return `Add MiniMax/GLM presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); OAuth/subscription with ${MODEL_ONBOARDING_OAUTH_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC.`;
29
+ return `Add MiniMax/GLM presets with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND}; custom API providers with ${MODEL_ONBOARDING_API_PROVIDER_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND}); OAuth/subscription with ${MODEL_ONBOARDING_OAUTH_COMMAND}; TypeSafe typed decisions with ${MODEL_ONBOARDING_TYPESAFE_COMMAND}; then run /model for DEFAULT, EXECUTOR, ARCHITECT, PLANNER, and CRITIC.`;
24
30
  }
25
31
 
26
32
  export function formatNoModelOnboardingError(): string {
@@ -1288,7 +1288,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1288
1288
  {
1289
1289
  name: "provider",
1290
1290
  description: "Set up API-compatible providers or login providers",
1291
- inlineHint: "add|login",
1291
+ inlineHint: "add|login|typesafe",
1292
1292
  allowArgs: true,
1293
1293
  handle: async (command, runtime) => {
1294
1294
  const args = command.args.trim();
@@ -1296,6 +1296,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1296
1296
  await runtime.output(providerSetupUsage());
1297
1297
  return commandConsumed();
1298
1298
  }
1299
+ if (args === "typesafe") {
1300
+ await runtime.output(
1301
+ "TypeSafe key entry needs an interactive terminal.\n" +
1302
+ "Run it in the TUI (/provider typesafe) or from a shell:\n" +
1303
+ " TYPESAFE_API_KEY=<key> skc setup typesafe\n" +
1304
+ " skc setup typesafe --remove",
1305
+ );
1306
+ return commandConsumed();
1307
+ }
1299
1308
  if (args === "login" || args.startsWith("login ")) {
1300
1309
  const providerId = args.slice("login".length).trim();
1301
1310
  const loginCommand = providerId ? `/login ${providerId}` : "/login [provider-id]";
@@ -1361,6 +1370,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1361
1370
  runtime.ctx.editor.setText("");
1362
1371
  return;
1363
1372
  }
1373
+ // TypeSafe is not a chat model, so it cannot live in the model list. A direct
1374
+ // subcommand keeps it one step away from `/model`, where users actually look
1375
+ // for "add a key", instead of buried in the onboarding menu.
1376
+ if (args === "typesafe") {
1377
+ runtime.ctx.showTypeSafeKeyPrompt();
1378
+ runtime.ctx.editor.setText("");
1379
+ return;
1380
+ }
1364
1381
  if (args.startsWith("add ")) {
1365
1382
  const parsed = parseProviderSetupSlashArgs(args.slice(4));
1366
1383
  try {