@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
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* `skc auth-gateway` command handlers.
|
|
3
3
|
*
|
|
4
4
|
* Boots a forward-proxy server that lets less-trusted clients (the macOS
|
|
5
|
-
* usage widget and containerized deployments) make provider
|
|
6
|
-
* seeing the access token.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* usage widget, local desktop apps and containerized deployments) make provider
|
|
6
|
+
* API calls without ever seeing the access token.
|
|
7
|
+
*
|
|
8
|
+
* Credential source mirrors `discoverAuthStorage()` in sdk/session.ts:
|
|
9
|
+
* - broker configured (`SKC_AUTH_BROKER_URL` / `auth.broker.url`) → the
|
|
10
|
+
* gateway is a broker client and never touches local SQLite.
|
|
11
|
+
* - no broker → the local SQLite store at `<agentDir>/agent.db`, so a
|
|
12
|
+
* single-machine user runs the gateway alone instead of also standing up
|
|
13
|
+
* `skc auth-broker serve`.
|
|
9
14
|
*
|
|
10
15
|
* Sub-verbs:
|
|
11
|
-
* - `serve [--bind=…]` — boots the gateway against the
|
|
16
|
+
* - `serve [--bind=…]` — boots the gateway against the broker or local store.
|
|
12
17
|
* - `token` / `token --regenerate` — manages the gateway bearer token file.
|
|
13
|
-
* - `status` — prints the locally-stored gateway token and
|
|
18
|
+
* - `status` — prints the locally-stored gateway token and credential source.
|
|
14
19
|
*/
|
|
15
20
|
import * as crypto from "node:crypto";
|
|
16
21
|
import * as fs from "node:fs/promises";
|
|
@@ -28,7 +33,7 @@ import {
|
|
|
28
33
|
type SnapshotResponse,
|
|
29
34
|
startAuthGateway,
|
|
30
35
|
} from "@sayknow-cli/ai";
|
|
31
|
-
import { getConfigRootDir, isEnoent, VERSION } from "@sayknow-cli/utils";
|
|
36
|
+
import { getAgentDbPath, getAgentDir, getConfigRootDir, isEnoent, VERSION } from "@sayknow-cli/utils";
|
|
32
37
|
import chalk from "chalk";
|
|
33
38
|
import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config";
|
|
34
39
|
|
|
@@ -128,29 +133,66 @@ async function fetchBrokerSnapshot(client: AuthBrokerClient): Promise<SnapshotRe
|
|
|
128
133
|
return result.snapshot;
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Credential source the gateway is serving from. `broker` mirrors the previous
|
|
138
|
+
* behaviour; `local` is the single-machine path that makes `skc auth-broker
|
|
139
|
+
* serve` optional.
|
|
140
|
+
*/
|
|
141
|
+
export interface GatewayCredentialSource {
|
|
142
|
+
storage: AuthStorage;
|
|
143
|
+
kind: "broker" | "local";
|
|
144
|
+
/** Broker URL in broker mode, `null` in local mode. */
|
|
145
|
+
brokerUrl: string | null;
|
|
146
|
+
/** `<agentDir>/agent.db` in local mode, `null` in broker mode. */
|
|
147
|
+
dbPath: string | null;
|
|
148
|
+
/** `broker <url>` / `local <dbPath>` — also used as the AuthStorage sourceLabel. */
|
|
149
|
+
label: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Open the credential store the gateway should serve from.
|
|
154
|
+
*
|
|
155
|
+
* Same precedence as `discoverAuthStorage()` in sdk/session.ts: a configured
|
|
156
|
+
* broker wins, otherwise fall back to the local SQLite store. Callers own the
|
|
157
|
+
* returned `storage` and must `close()` it.
|
|
158
|
+
*/
|
|
159
|
+
export async function openGatewayCredentialSource(): Promise<GatewayCredentialSource> {
|
|
132
160
|
const brokerConfig = await resolveAuthBrokerConfig();
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
161
|
+
if (brokerConfig) {
|
|
162
|
+
// Refresh + usage both flow through the store's broker hooks automatically —
|
|
163
|
+
// `RemoteAuthCredentialStore.refreshOAuthCredential` and `.fetchUsageReports`.
|
|
164
|
+
// AuthStorage discovers them when no explicit option overrides them, so the
|
|
165
|
+
// gateway only needs to construct the store and pass it in.
|
|
166
|
+
const client = createBrokerClient(brokerConfig);
|
|
167
|
+
const initialSnapshot = await fetchBrokerSnapshot(client);
|
|
168
|
+
const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
|
|
169
|
+
const label = `broker ${brokerConfig.url}`;
|
|
170
|
+
const storage = new AuthStorage(store, { sourceLabel: label });
|
|
171
|
+
try {
|
|
172
|
+
await storage.reload();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
try {
|
|
175
|
+
storage.close();
|
|
176
|
+
} catch {
|
|
177
|
+
// Preserve the initial reload failure.
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return { storage, kind: "broker", brokerUrl: brokerConfig.url, dbPath: null, label };
|
|
137
182
|
}
|
|
183
|
+
const dbPath = getAgentDbPath(getAgentDir());
|
|
184
|
+
const label = `local ${dbPath}`;
|
|
185
|
+
// `AuthStorage.create` opens the store and reloads it in one step.
|
|
186
|
+
const storage = await AuthStorage.create(dbPath, { sourceLabel: label });
|
|
187
|
+
return { storage, kind: "local", brokerUrl: null, dbPath, label };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
138
191
|
const bind = flags.bind ?? DEFAULT_AUTH_GATEWAY_BIND;
|
|
139
192
|
const gatewayToken = flags.noAuth ? null : await ensureToken();
|
|
140
193
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const client = createBrokerClient(brokerConfig);
|
|
144
|
-
const initialSnapshot = await fetchBrokerSnapshot(client);
|
|
145
|
-
const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
|
|
146
|
-
// Refresh + usage both flow through the store's broker hooks automatically —
|
|
147
|
-
// `RemoteAuthCredentialStore.refreshOAuthCredential` and `.fetchUsageReports`.
|
|
148
|
-
// AuthStorage discovers them when no explicit option overrides them, so the
|
|
149
|
-
// gateway only needs to construct the store and pass it in.
|
|
150
|
-
const storage = new AuthStorage(store, {
|
|
151
|
-
sourceLabel: `broker ${brokerConfig.url}`,
|
|
152
|
-
});
|
|
153
|
-
await storage.reload();
|
|
194
|
+
const source = await openGatewayCredentialSource();
|
|
195
|
+
const storage = source.storage;
|
|
154
196
|
|
|
155
197
|
// Build the model resolver + catalog from pi-ai's bundled metadata, scoped
|
|
156
198
|
// to providers we hold credentials for. Format handlers ask `resolveModel`
|
|
@@ -183,7 +225,11 @@ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
183
225
|
} else {
|
|
184
226
|
process.stdout.write(`auth: disabled (--no-auth) — any client can call this gateway\n`);
|
|
185
227
|
}
|
|
186
|
-
|
|
228
|
+
if (source.kind === "broker") {
|
|
229
|
+
process.stdout.write(`upstream broker: ${source.brokerUrl}\n`);
|
|
230
|
+
} else {
|
|
231
|
+
process.stdout.write(`credentials: local ${source.dbPath}\n`);
|
|
232
|
+
}
|
|
187
233
|
|
|
188
234
|
const stopped = Promise.withResolvers<void>();
|
|
189
235
|
let shutdownStarted = false;
|
|
@@ -243,24 +289,35 @@ async function runToken(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
243
289
|
|
|
244
290
|
async function runStatus(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
245
291
|
const token = await readToken();
|
|
246
|
-
const brokerConfig = await resolveAuthBrokerConfig();
|
|
247
292
|
const tokenFile = getTokenFilePath();
|
|
248
|
-
|
|
293
|
+
const tokenPresent = token !== null;
|
|
294
|
+
|
|
295
|
+
let source: GatewayCredentialSource;
|
|
296
|
+
try {
|
|
297
|
+
source = await openGatewayCredentialSource();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// Broker unreachable, or the local SQLite store failed to open.
|
|
300
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
301
|
+
const brokerConfig = await resolveAuthBrokerConfig().catch(() => null);
|
|
249
302
|
const status = {
|
|
250
303
|
ready: false,
|
|
251
|
-
reason: "
|
|
304
|
+
reason: brokerConfig ? "broker_unavailable" : "local_store_unavailable",
|
|
305
|
+
source: brokerConfig ? "broker" : "local",
|
|
252
306
|
tokenFile,
|
|
253
|
-
tokenPresent
|
|
254
|
-
broker: null,
|
|
255
|
-
brokerConfigured:
|
|
307
|
+
tokenPresent,
|
|
308
|
+
broker: brokerConfig?.url ?? null,
|
|
309
|
+
brokerConfigured: brokerConfig !== null,
|
|
256
310
|
brokerAuthenticated: false,
|
|
311
|
+
dbPath: brokerConfig ? null : getAgentDbPath(getAgentDir()),
|
|
312
|
+
error: message,
|
|
257
313
|
};
|
|
258
314
|
if (flags.json) {
|
|
259
315
|
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
260
316
|
} else {
|
|
261
|
-
|
|
317
|
+
const what = brokerConfig ? `upstream broker: ${brokerConfig.url}` : `local store: ${status.dbPath}`;
|
|
318
|
+
process.stdout.write(`${chalk.red("FAILED")} ${what}: ${message}\n`);
|
|
262
319
|
process.stdout.write(
|
|
263
|
-
`token: ${
|
|
320
|
+
`token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${tokenFile}\n`,
|
|
264
321
|
);
|
|
265
322
|
}
|
|
266
323
|
process.exitCode = 1;
|
|
@@ -268,56 +325,50 @@ async function runStatus(flags: AuthGatewayCommandArgs["flags"]): Promise<void>
|
|
|
268
325
|
}
|
|
269
326
|
|
|
270
327
|
try {
|
|
271
|
-
const
|
|
272
|
-
|
|
328
|
+
const credentialCount = source.storage.exportSnapshot().credentials.length;
|
|
329
|
+
// Ready means a client can actually get an answer: it needs a bearer
|
|
330
|
+
// token to present, and we need at least one provider credential to serve.
|
|
331
|
+
const ready = tokenPresent && credentialCount > 0;
|
|
273
332
|
const status = {
|
|
274
|
-
ready
|
|
275
|
-
reason: tokenPresent ?
|
|
333
|
+
ready,
|
|
334
|
+
reason: !tokenPresent ? "token_missing" : credentialCount === 0 ? "no_credentials" : null,
|
|
335
|
+
source: source.kind,
|
|
276
336
|
tokenFile,
|
|
277
337
|
tokenPresent,
|
|
278
|
-
broker:
|
|
279
|
-
brokerConfigured:
|
|
280
|
-
brokerAuthenticated:
|
|
281
|
-
|
|
338
|
+
broker: source.brokerUrl,
|
|
339
|
+
brokerConfigured: source.kind === "broker",
|
|
340
|
+
brokerAuthenticated: source.kind === "broker",
|
|
341
|
+
dbPath: source.dbPath,
|
|
342
|
+
credentialCount,
|
|
282
343
|
};
|
|
283
344
|
if (flags.json) {
|
|
284
345
|
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
285
346
|
} else {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
347
|
+
const plural = credentialCount === 1 ? "" : "s";
|
|
348
|
+
const sourceLine =
|
|
349
|
+
source.kind === "broker"
|
|
350
|
+
? `upstream broker: ${source.brokerUrl} (${credentialCount} credential${plural})`
|
|
351
|
+
: `local store: ${source.dbPath} (${credentialCount} credential${plural})`;
|
|
352
|
+
process.stdout.write(`${ready ? chalk.green("ready") : chalk.yellow("not ready")} ${sourceLine}\n`);
|
|
290
353
|
process.stdout.write(
|
|
291
|
-
`token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${
|
|
354
|
+
`token: ${tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${tokenFile}\n`,
|
|
292
355
|
);
|
|
293
356
|
if (!tokenPresent) {
|
|
294
357
|
process.stdout.write(
|
|
295
358
|
"Run `skc auth-gateway token` or `skc auth-gateway serve` to create a bearer token.\n",
|
|
296
359
|
);
|
|
297
360
|
}
|
|
361
|
+
if (credentialCount === 0) {
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
source.kind === "broker"
|
|
364
|
+
? "The broker holds no credentials. Log in on the broker host.\n"
|
|
365
|
+
: "No local credentials. Run `skc auth-broker login <provider>` (e.g. anthropic).\n",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
298
368
|
}
|
|
299
|
-
if (!
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const status = {
|
|
303
|
-
ready: false,
|
|
304
|
-
reason: "broker_unavailable",
|
|
305
|
-
tokenFile,
|
|
306
|
-
tokenPresent: token !== null,
|
|
307
|
-
broker: brokerConfig.url,
|
|
308
|
-
brokerConfigured: true,
|
|
309
|
-
brokerAuthenticated: false,
|
|
310
|
-
error: message,
|
|
311
|
-
};
|
|
312
|
-
if (flags.json) {
|
|
313
|
-
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
314
|
-
} else {
|
|
315
|
-
process.stdout.write(`${chalk.red("FAILED")} upstream broker: ${brokerConfig.url}: ${message}\n`);
|
|
316
|
-
process.stdout.write(
|
|
317
|
-
`token: ${status.tokenPresent ? chalk.green("present") : chalk.red("missing")} at ${status.tokenFile}\n`,
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
process.exitCode = 1;
|
|
369
|
+
if (!ready) process.exitCode = 1;
|
|
370
|
+
} finally {
|
|
371
|
+
source.storage.close();
|
|
321
372
|
}
|
|
322
373
|
}
|
|
323
374
|
|
|
@@ -343,30 +394,22 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
|
|
|
343
394
|
}
|
|
344
395
|
|
|
345
396
|
/**
|
|
346
|
-
* `skc auth-gateway check` — probe each
|
|
347
|
-
* per-credential auth health. Use this when the gateway is returning 401s
|
|
397
|
+
* `skc auth-gateway check` — probe each credential the gateway would serve and
|
|
398
|
+
* print per-credential auth health. Use this when the gateway is returning 401s
|
|
348
399
|
* you need to find which row in a multi-account pool is the bad one. The
|
|
349
400
|
* aggregate `/v1/usage` endpoint silently drops failed credentials, so a
|
|
350
401
|
* dedicated diagnostic is the only way to see which credentials failed.
|
|
351
402
|
*/
|
|
352
403
|
async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
throw new Error(
|
|
356
|
-
"`skc auth-gateway check` requires SKC_AUTH_BROKER_URL (or `auth.broker.url`/`auth.broker.token` in config.yml). It probes the same credentials the gateway would serve.",
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const client = createBrokerClient(brokerConfig);
|
|
361
|
-
const initialSnapshot = await fetchBrokerSnapshot(client);
|
|
362
|
-
const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
|
|
363
|
-
const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
|
|
404
|
+
const source = await openGatewayCredentialSource();
|
|
405
|
+
const storage = source.storage;
|
|
364
406
|
try {
|
|
365
|
-
await storage.reload();
|
|
366
407
|
const results = await storage.checkCredentials();
|
|
367
408
|
|
|
368
409
|
if (flags.json) {
|
|
369
|
-
process.stdout.write(
|
|
410
|
+
process.stdout.write(
|
|
411
|
+
`${JSON.stringify({ source: source.kind, broker: source.brokerUrl, dbPath: source.dbPath, credentials: results }, null, 2)}\n`,
|
|
412
|
+
);
|
|
370
413
|
} else {
|
|
371
414
|
const grouped = new Map<string, typeof results>();
|
|
372
415
|
for (const row of results) {
|
|
@@ -375,7 +418,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
375
418
|
grouped.set(row.provider, list);
|
|
376
419
|
}
|
|
377
420
|
const providers = [...grouped.keys()].sort();
|
|
378
|
-
process.stdout.write(
|
|
421
|
+
process.stdout.write(`${source.label}\n`);
|
|
379
422
|
for (const provider of providers) {
|
|
380
423
|
const rows = grouped.get(provider) ?? [];
|
|
381
424
|
process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
|
package/src/cli/setup-cli.ts
CHANGED
|
@@ -48,6 +48,7 @@ export type SetupComponent =
|
|
|
48
48
|
| "provider"
|
|
49
49
|
| "python"
|
|
50
50
|
| "stt"
|
|
51
|
+
| "typesafe"
|
|
51
52
|
| "ui-skills";
|
|
52
53
|
|
|
53
54
|
export interface SetupCommandArgs {
|
|
@@ -81,6 +82,8 @@ export interface SetupCommandArgs {
|
|
|
81
82
|
yes?: boolean;
|
|
82
83
|
dryRun?: boolean;
|
|
83
84
|
keychain?: boolean;
|
|
85
|
+
skipVerify?: boolean;
|
|
86
|
+
remove?: boolean;
|
|
84
87
|
};
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -94,6 +97,7 @@ const VALID_COMPONENTS: SetupComponent[] = [
|
|
|
94
97
|
"provider",
|
|
95
98
|
"python",
|
|
96
99
|
"stt",
|
|
100
|
+
"typesafe",
|
|
97
101
|
];
|
|
98
102
|
|
|
99
103
|
function hasProviderSetupFlags(flags: SetupCommandArgs["flags"]): boolean {
|
|
@@ -188,8 +192,12 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
|
|
|
188
192
|
} else if (arg === "--base-url") {
|
|
189
193
|
flags.baseUrl = args[++i];
|
|
190
194
|
} else if (arg === "--api-key") {
|
|
191
|
-
console.error(chalk.red("
|
|
195
|
+
console.error(chalk.red("Setup rejects raw --api-key values; pass the key through an environment variable."));
|
|
192
196
|
process.exit(1);
|
|
197
|
+
} else if (arg === "--skip-verify") {
|
|
198
|
+
flags.skipVerify = true;
|
|
199
|
+
} else if (arg === "--remove") {
|
|
200
|
+
flags.remove = true;
|
|
193
201
|
} else if (arg === "--api-key-env") {
|
|
194
202
|
flags.apiKeyEnv = args[++i];
|
|
195
203
|
} else if (arg === "--model" || arg === "--models") {
|
|
@@ -289,6 +297,9 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
|
|
|
289
297
|
case "stt":
|
|
290
298
|
await handleSttSetup(cmd.flags);
|
|
291
299
|
break;
|
|
300
|
+
case "typesafe":
|
|
301
|
+
await handleTypeSafeSetup(cmd.flags);
|
|
302
|
+
break;
|
|
292
303
|
case "credentials":
|
|
293
304
|
await handleCredentialsSetup(cmd.flags);
|
|
294
305
|
break;
|
|
@@ -802,3 +813,44 @@ ${chalk.bold("Examples:")}
|
|
|
802
813
|
${APP_NAME} setup credentials --yes Import without an interactive prompt
|
|
803
814
|
`);
|
|
804
815
|
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* `skc setup typesafe` — enable the hosted System One model for typed decisions.
|
|
819
|
+
*
|
|
820
|
+
* The key is read from `TYPESAFE_API_KEY`, never from a flag: this repo already refuses
|
|
821
|
+
* raw `--api-key` values because they land in shell history and in the process list of
|
|
822
|
+
* every user on the machine. Same rule applies here.
|
|
823
|
+
*
|
|
824
|
+
* Verified against the live API before storing. The decision service fails open, so an
|
|
825
|
+
* unverified bad key would be swallowed forever — the user would believe TypeSafe was
|
|
826
|
+
* active while every decision quietly came from their own model.
|
|
827
|
+
*/
|
|
828
|
+
export async function handleTypeSafeSetup(flags: SetupCommandArgs["flags"]): Promise<void> {
|
|
829
|
+
const { formatTypeSafeKeyResult, removeTypeSafeKey, setTypeSafeKey } = await import("../setup/decision-provider");
|
|
830
|
+
|
|
831
|
+
if (flags.remove) {
|
|
832
|
+
await removeTypeSafeKey();
|
|
833
|
+
process.stdout.write("TypeSafe key removed. Typed decisions fall back to your logged-in model.\n");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const apiKey = process.env.TYPESAFE_API_KEY?.trim();
|
|
838
|
+
if (!apiKey) {
|
|
839
|
+
process.stdout.write(
|
|
840
|
+
`Usage: TYPESAFE_API_KEY=<key> ${APP_NAME} setup typesafe [--skip-verify]\n` +
|
|
841
|
+
` ${APP_NAME} setup typesafe --remove\n\n` +
|
|
842
|
+
"The key is taken from the environment on purpose: a flag would leak it into\n" +
|
|
843
|
+
"shell history and the process list.\n",
|
|
844
|
+
);
|
|
845
|
+
process.exitCode = 1;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const result = await setTypeSafeKey({ apiKey, skipVerify: flags.skipVerify });
|
|
850
|
+
if (flags.json) {
|
|
851
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
852
|
+
} else {
|
|
853
|
+
process.stdout.write(`${formatTypeSafeKeyResult(result)}\n`);
|
|
854
|
+
}
|
|
855
|
+
if (result.error) process.exitCode = 1;
|
|
856
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skc auth-gateway` — run a forward proxy that injects auth from the broker
|
|
2
|
+
* `skc auth-gateway` — run a forward proxy that injects auth from the broker,
|
|
3
|
+
* or from the local credential store when no broker is configured.
|
|
3
4
|
*/
|
|
4
5
|
import { Args, Command, Flags, renderCommandHelp } from "@sayknow-cli/utils/cli";
|
|
5
6
|
import {
|
|
@@ -11,7 +12,8 @@ import {
|
|
|
11
12
|
import { initTheme } from "../modes/theme/theme";
|
|
12
13
|
|
|
13
14
|
export default class AuthGateway extends Command {
|
|
14
|
-
static description =
|
|
15
|
+
static description =
|
|
16
|
+
"Run an auth-gateway forward proxy backed by the configured broker, or local credentials when no broker is set";
|
|
15
17
|
|
|
16
18
|
static args = {
|
|
17
19
|
action: Args.string({
|
|
@@ -32,13 +34,13 @@ export default class AuthGateway extends Command {
|
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
static examples = [
|
|
35
|
-
"# Boot the gateway
|
|
37
|
+
"# Boot the gateway (broker when configured, else local credentials)\n skc auth-gateway serve",
|
|
36
38
|
"# Boot on a non-default port\n skc auth-gateway serve --bind=127.0.0.1:4000",
|
|
37
39
|
"# Print the gateway bearer token (creates one on first run)\n skc auth-gateway token",
|
|
38
40
|
"# Rotate the gateway bearer token\n skc auth-gateway token --regenerate",
|
|
39
41
|
"# Run on loopback without any bearer (anyone on this host can call)\n skc auth-gateway serve --no-auth",
|
|
40
|
-
"# Show
|
|
41
|
-
"# Probe each
|
|
42
|
+
"# Show gateway token + credential source status\n skc auth-gateway status",
|
|
43
|
+
"# Probe each credential to see which one is producing 401s\n skc auth-gateway check",
|
|
42
44
|
"# Same, machine-readable for scripts\n skc auth-gateway check --json",
|
|
43
45
|
];
|
|
44
46
|
|
package/src/commands/setup.ts
CHANGED
|
@@ -15,6 +15,7 @@ const COMPONENTS: SetupComponent[] = [
|
|
|
15
15
|
"provider",
|
|
16
16
|
"python",
|
|
17
17
|
"stt",
|
|
18
|
+
"typesafe",
|
|
18
19
|
"ui-skills",
|
|
19
20
|
];
|
|
20
21
|
|
|
@@ -60,6 +61,8 @@ export default class Setup extends Command {
|
|
|
60
61
|
"models-path": Flags.string({ description: "Override models config path" }),
|
|
61
62
|
yes: Flags.boolean({ char: "y", description: "Import discovered credentials without an interactive prompt" }),
|
|
62
63
|
"dry-run": Flags.boolean({ description: "Preview discovered credentials without importing" }),
|
|
64
|
+
"skip-verify": Flags.boolean({ description: "Store the TypeSafe key without checking it against the live API" }),
|
|
65
|
+
remove: Flags.boolean({ description: "Remove the stored TypeSafe key" }),
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
async run(): Promise<void> {
|
|
@@ -94,6 +97,8 @@ export default class Setup extends Command {
|
|
|
94
97
|
profileDir: flags["profile-dir"],
|
|
95
98
|
yes: flags.yes,
|
|
96
99
|
dryRun: flags["dry-run"],
|
|
100
|
+
skipVerify: flags["skip-verify"],
|
|
101
|
+
remove: flags.remove,
|
|
97
102
|
},
|
|
98
103
|
};
|
|
99
104
|
await initTheme();
|
|
@@ -1999,6 +1999,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
1999
1999
|
"hindsight.mentalModelRefreshIntervalMs": { type: "number", default: 5 * 60 * 1000 },
|
|
2000
2000
|
"hindsight.mentalModelMaxRenderChars": { type: "number", default: 16_000 },
|
|
2001
2001
|
|
|
2002
|
+
// Typed decisions
|
|
2003
|
+
"decisions.enabled": {
|
|
2004
|
+
type: "boolean",
|
|
2005
|
+
default: false,
|
|
2006
|
+
ui: {
|
|
2007
|
+
tab: "context",
|
|
2008
|
+
label: "Typed decisions",
|
|
2009
|
+
description:
|
|
2010
|
+
"Let a cheap model answer typed questions the deterministic rules cannot. Currently routes workflow skills when the keyword table finds nothing — which is every non-English phrasing. Costs one small model call on those prompts; every failure falls back to today's behaviour.",
|
|
2011
|
+
},
|
|
2012
|
+
},
|
|
2013
|
+
|
|
2002
2014
|
// TTSR
|
|
2003
2015
|
"ttsr.enabled": {
|
|
2004
2016
|
type: "boolean",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed decisions — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Call sites ask for a judgment and get a typed answer or nothing. They never learn
|
|
5
|
+
* which backend answered, and they never have to handle a transport error: every
|
|
6
|
+
* failure path resolves `null`. That is deliberate — a decision service is an
|
|
7
|
+
* *enhancement* to code that already works, so an outage must degrade behaviour to
|
|
8
|
+
* the previous default rather than break the turn.
|
|
9
|
+
*/
|
|
10
|
+
import { logger } from "@sayknow-cli/utils";
|
|
11
|
+
import { createLlmDecisionBackend, type LlmBackendDeps } from "./llm-backend";
|
|
12
|
+
import type { DecisionBackend, DecisionRequest, DecisionResult } from "./types";
|
|
13
|
+
import { createTypeSafeDecisionBackend } from "./typesafe-backend";
|
|
14
|
+
|
|
15
|
+
export { createLlmDecisionBackend } from "./llm-backend";
|
|
16
|
+
export * from "./types";
|
|
17
|
+
export { createTypeSafeDecisionBackend, TYPESAFE_PROVIDER } from "./typesafe-backend";
|
|
18
|
+
|
|
19
|
+
/** Hard ceiling. A decision that takes longer than this is worthless to the caller. */
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 8_000;
|
|
21
|
+
|
|
22
|
+
export interface DecisionServiceOptions extends LlmBackendDeps {
|
|
23
|
+
/** Off by default; callers opt in per feature. */
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** Injection point for tests and for the self-hosted/hosted backends. */
|
|
27
|
+
backends?: DecisionBackend[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DecisionService {
|
|
31
|
+
readonly enabled: boolean;
|
|
32
|
+
/** Resolves null when disabled, unavailable, timed out, or the model misbehaved. */
|
|
33
|
+
decide(request: DecisionRequest): Promise<DecisionResult | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDecisionService(options: DecisionServiceOptions): DecisionService {
|
|
37
|
+
const enabled = options.enabled ?? false;
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
39
|
+
/**
|
|
40
|
+
* Order matters and is not configurable by accident.
|
|
41
|
+
*
|
|
42
|
+
* TypeSafe first *when a key exists*: it is the only backend that returns calibrated
|
|
43
|
+
* probabilities, and it resolves `null` immediately when no key is stored, so users
|
|
44
|
+
* who never added one pay nothing for it being in the list.
|
|
45
|
+
*
|
|
46
|
+
* The user's logged-in model is the fallback and the default experience: no extra
|
|
47
|
+
* vendor, no extra key, works offline of TypeSafe entirely.
|
|
48
|
+
*/
|
|
49
|
+
const backends = options.backends ?? [createTypeSafeDecisionBackend(options), createLlmDecisionBackend(options)];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
enabled,
|
|
53
|
+
async decide(request: DecisionRequest): Promise<DecisionResult | null> {
|
|
54
|
+
if (!enabled || backends.length === 0) return null;
|
|
55
|
+
for (const backend of backends) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const abortOnCallerSignal = () => controller.abort();
|
|
58
|
+
request.signal?.addEventListener("abort", abortOnCallerSignal, { once: true });
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
// The deadline must be a race, not just an abort. A provider that ignores
|
|
62
|
+
// its signal would otherwise hang the caller's turn forever — and the
|
|
63
|
+
// caller here is the user's prompt, so "forever" means a frozen session.
|
|
64
|
+
const deadline = new Promise<null>(resolve => {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
controller.abort();
|
|
67
|
+
resolve(null);
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
});
|
|
70
|
+
const result = await Promise.race([backend.decide({ ...request, signal: controller.signal }), deadline]);
|
|
71
|
+
if (result) return result;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Fail open: log and try the next backend, then give up quietly.
|
|
74
|
+
logger.debug("decisions: backend failed", { backend: backend.name, error: String(error) });
|
|
75
|
+
} finally {
|
|
76
|
+
if (timer) clearTimeout(timer);
|
|
77
|
+
controller.abort();
|
|
78
|
+
request.signal?.removeEventListener("abort", abortOnCallerSignal);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|