@sayknow-cli/coding-agent 0.3.9 → 0.3.11

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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +11 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  13. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  14. package/dist/types/modes/types.d.ts +1 -0
  15. package/dist/types/notifications/config-commands.d.ts +29 -0
  16. package/dist/types/notifications/index.d.ts +12 -1
  17. package/dist/types/notifications/threaded-render.d.ts +2 -0
  18. package/dist/types/notifications/topic-registry.d.ts +4 -5
  19. package/dist/types/runtime-credential-selector.d.ts +7 -0
  20. package/dist/types/sdk.d.ts +7 -1
  21. package/dist/types/session/agent-session.d.ts +6 -3
  22. package/dist/types/tools/index.d.ts +1 -0
  23. package/dist/types/tools/skill-discovery.d.ts +40 -0
  24. package/package.json +7 -7
  25. package/src/cli/args.ts +8 -0
  26. package/src/cli/notify-cli.ts +27 -1
  27. package/src/cli/update-cli.ts +24 -63
  28. package/src/cli.ts +5 -0
  29. package/src/commands/launch.ts +5 -0
  30. package/src/commands/notify.ts +2 -2
  31. package/src/config/model-profile-activation.ts +23 -7
  32. package/src/config/model-registry.ts +107 -11
  33. package/src/config/model-resolver.ts +5 -16
  34. package/src/config/settings-schema.ts +3 -2
  35. package/src/config/settings.ts +24 -1
  36. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  37. package/src/internal-urls/docs-index.generated.ts +1 -1
  38. package/src/main.ts +26 -3
  39. package/src/modes/components/settings-selector.ts +3 -3
  40. package/src/modes/components/thinking-selector.ts +79 -18
  41. package/src/modes/controllers/input-controller.ts +50 -9
  42. package/src/modes/controllers/selector-controller.ts +7 -5
  43. package/src/modes/types.ts +1 -0
  44. package/src/modes/utils/ui-helpers.ts +48 -15
  45. package/src/notifications/config-commands.ts +70 -0
  46. package/src/notifications/index.ts +156 -9
  47. package/src/notifications/rich-render.ts +1 -0
  48. package/src/notifications/telegram-daemon.ts +87 -4
  49. package/src/notifications/threaded-render.ts +14 -0
  50. package/src/notifications/topic-registry.ts +9 -8
  51. package/src/prompts/tools/skill-discovery.md +13 -0
  52. package/src/runtime-credential-selector.ts +35 -0
  53. package/src/sdk.ts +68 -5
  54. package/src/session/agent-session.ts +42 -18
  55. package/src/skc-runtime/psmux-detect.ts +17 -2
  56. package/src/system-prompt.ts +4 -6
  57. package/src/tools/index.ts +4 -0
  58. package/src/tools/skill-discovery.ts +73 -0
  59. package/src/tools/skill.ts +15 -2
@@ -14,10 +14,9 @@ import { installDefaultSkcDefinitions } from "../defaults/skc-defaults";
14
14
  import { theme } from "../modes/theme/theme";
15
15
 
16
16
  const RELEASE_REPO = "jaybeyond/Sayknow_CLI";
17
- const RELEASE_TAG_PREFIX = "sayknow-v";
18
- const PACKAGE = "sayknow-cli";
19
- const SCOPED_CLI_PACKAGE = "@sayknow-cli/coding-agent";
20
- const NPM_MANAGED_PACKAGES = [PACKAGE, SCOPED_CLI_PACKAGE] as const;
17
+ const PACKAGE = "@sayknow-cli/coding-agent";
18
+ const NPM_WRAPPER_PACKAGE = "sayknow-cli";
19
+ const NPM_MANAGED_PACKAGES = [NPM_WRAPPER_PACKAGE, PACKAGE] as const;
21
20
 
22
21
  interface ReleaseInfo {
23
22
  tag: string;
@@ -137,79 +136,35 @@ function defaultPackageExists(_packageName: string, packageRoot: string): boolea
137
136
  return fs.existsSync(path.join(packageRoot, "package.json"));
138
137
  }
139
138
 
140
- function npmPackageRootForWindowsShim(binPath: string, packageName: string, platform: PathPlatform): string {
139
+ function npmPackageRootForBinPath(binPath: string, packageName: string, platform: PathPlatform): string {
141
140
  const pathApi = pathApiForPlatform(platform);
142
141
  const segments = packageName.split("/");
143
142
  return pathApi.join(pathApi.dirname(binPath), "node_modules", ...segments);
144
143
  }
145
144
 
146
- function packageRootFromNodeModulesPath(
147
- binPath: string,
148
- packageName: string,
149
- platform: PathPlatform,
150
- ): string | undefined {
151
- const pathApi = pathApiForPlatform(platform);
152
- const packageSegments = packageName.split("/");
153
- const parts = pathApi.normalize(binPath).split(pathApi.sep).filter(Boolean);
154
- for (let index = 0; index < parts.length; index++) {
155
- if (parts[index] !== "node_modules") continue;
156
- const packageStart = index + 1;
157
- const matched = packageSegments.every((segment, offset) => parts[packageStart + offset] === segment);
158
- if (!matched) continue;
159
- const prefix = binPath.startsWith(pathApi.sep) ? pathApi.sep : "";
160
- return pathApi.join(prefix, ...parts.slice(0, packageStart + packageSegments.length));
161
- }
162
- return undefined;
163
- }
164
-
165
- function npmPackageRootsForPosixShim(binPath: string, packageName: string): string[] {
166
- const pathApi = path.posix;
167
- const binDir = pathApi.dirname(binPath);
168
- const packageSegments = packageName.split("/");
169
- return [
170
- pathApi.join(pathApi.dirname(binDir), "lib", "node_modules", ...packageSegments),
171
- pathApi.join(binDir, "node_modules", ...packageSegments),
172
- ];
173
- }
174
-
175
145
  function resolveNpmManagedTarget(
176
146
  ompPath: string,
177
147
  platform: PathPlatform = process.platform,
178
148
  packageExists: PackageExists = defaultPackageExists,
179
149
  ): PackageManagerTarget | undefined {
150
+ if (platform !== "win32") return undefined;
180
151
  const pathApi = pathApiForPlatform(platform);
181
- const basename = pathApi.basename(ompPath, pathApi.extname(ompPath)).toLowerCase();
182
- const resolvedPath = tryRealpath(ompPath);
183
- const candidatePaths = resolvedPath ? [resolvedPath, ompPath] : [ompPath];
184
-
185
- if (platform === "win32") {
186
- const extension = pathApi.extname(ompPath).toLowerCase();
187
- if (extension !== ".cmd" && extension !== ".ps1") return undefined;
188
- if (basename !== APP_NAME.toLowerCase()) return undefined;
189
- for (const packageName of NPM_MANAGED_PACKAGES) {
190
- const packageRoot = npmPackageRootForWindowsShim(ompPath, packageName, platform);
191
- if (packageExists(packageName, packageRoot)) return { manager: "npm", packageName };
192
- }
193
- return undefined;
194
- }
195
-
152
+ const extension = pathApi.extname(ompPath).toLowerCase();
153
+ if (extension !== ".cmd" && extension !== ".ps1") return undefined;
154
+ const basename = pathApi.basename(ompPath, extension).toLowerCase();
196
155
  if (basename !== APP_NAME.toLowerCase()) return undefined;
156
+
197
157
  for (const packageName of NPM_MANAGED_PACKAGES) {
198
- for (const candidatePath of candidatePaths) {
199
- const packageRoot = packageRootFromNodeModulesPath(candidatePath, packageName, platform);
200
- if (packageRoot && packageExists(packageName, packageRoot)) return { manager: "npm", packageName };
201
- }
202
- for (const packageRoot of npmPackageRootsForPosixShim(ompPath, packageName)) {
203
- if (packageExists(packageName, packageRoot)) return { manager: "npm", packageName };
204
- }
158
+ const packageRoot = npmPackageRootForBinPath(ompPath, packageName, platform);
159
+ if (packageExists(packageName, packageRoot)) return { manager: "npm", packageName };
205
160
  }
206
161
  return undefined;
207
162
  }
208
163
 
209
164
  function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "npm" | "binary" {
210
- if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun";
211
165
  if (resolveNpmManagedTarget(ompPath)) return "npm";
212
- return "binary";
166
+ if (!bunBinDir) return "binary";
167
+ return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
213
168
  }
214
169
 
215
170
  export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "npm" | "binary" {
@@ -228,10 +183,16 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
228
183
  const ompPath = resolveSkcPath();
229
184
 
230
185
  if (ompPath) {
231
- if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return { method: "bun" };
232
186
  const npmTarget = resolveNpmManagedTarget(ompPath);
233
187
  if (npmTarget) return { method: "npm", packageName: npmTarget.packageName };
234
- return { method: "binary", path: ompPath };
188
+ const method = resolveUpdateMethod(ompPath, bunBinDir);
189
+ if (method === "bun") return { method };
190
+ if (method === "npm") {
191
+ throw new Error(
192
+ formatUnsupportedTargetMessage(`Could not resolve npm package root for ${APP_NAME} shim ${ompPath}`),
193
+ );
194
+ }
195
+ return { method, path: ompPath };
235
196
  }
236
197
 
237
198
  if (bunBinDir) return { method: "bun" };
@@ -251,7 +212,7 @@ async function getLatestRelease(): Promise<ReleaseInfo> {
251
212
 
252
213
  const data = (await response.json()) as { version: string };
253
214
  const version = data.version;
254
- const tag = `${RELEASE_TAG_PREFIX}${version}`;
215
+ const tag = `v${version}`;
255
216
 
256
217
  return {
257
218
  tag,
@@ -389,7 +350,7 @@ function formatBinaryInstallInstruction(platform: NodeJS.Platform = process.plat
389
350
  function formatManualUpdateInstructions(platform: NodeJS.Platform = process.platform): string {
390
351
  return [
391
352
  `If ${APP_NAME} was installed with Bun, run: bun install -g ${PACKAGE}@latest`,
392
- `If ${APP_NAME} was installed with npm, pnpm, or another package manager, run: npm install -g ${PACKAGE}@latest`,
353
+ `If ${APP_NAME} was installed with npm, pnpm, or another package manager, update it with that same manager.`,
393
354
  formatBinaryInstallInstruction(platform),
394
355
  ].join("\n");
395
356
  }
@@ -404,7 +365,7 @@ function buildReleaseBinaryUrl(
404
365
  arch: string = process.arch,
405
366
  ): string {
406
367
  const binaryName = getBinaryName(platform, arch);
407
- const tag = `${RELEASE_TAG_PREFIX}${version}`;
368
+ const tag = `v${version}`;
408
369
  return `https://github.com/${RELEASE_REPO}/releases/download/${tag}/${binaryName}`;
409
370
  }
410
371
 
package/src/cli.ts CHANGED
@@ -146,6 +146,10 @@ export class RootHelpCommand extends Command {
146
146
  default: Flags.boolean({ description: "Persist --mpreset as the default model profile" }),
147
147
  provider: Flags.string({ description: "Provider to use (legacy; prefer --model)" }),
148
148
  "api-key": Flags.string({ description: "API key (defaults to env vars)" }),
149
+ credential: Flags.string({
150
+ description:
151
+ "Stored credential selector: email:<addr>, id:<n>, account:<id>, project:<id>, or provider/email:<addr>",
152
+ }),
149
153
  "system-prompt": Flags.string({ description: "System prompt (default: coding assistant prompt)" }),
150
154
  "append-system-prompt": Flags.string({ description: "Append text or file contents to the system prompt" }),
151
155
  "allow-home": Flags.boolean({ description: "Allow starting in ~ without auto-switching to a temp dir" }),
@@ -191,6 +195,7 @@ export class RootHelpCommand extends Command {
191
195
  `# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
192
196
  `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
193
197
  `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
198
+ `# Pin a stored credential for this session\n ${APP_NAME} --credential email:me@example.com`,
194
199
  `# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
195
200
  `# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
196
201
  `# Export a session file to HTML\n ${APP_NAME} --export ~/.skc/agent/sessions/--path--/session.jsonl`,
@@ -87,6 +87,10 @@ export default class Index extends Command {
87
87
  "api-key": Flags.string({
88
88
  description: "API key (defaults to env vars)",
89
89
  }),
90
+ credential: Flags.string({
91
+ description:
92
+ "Stored credential selector: email:<addr>, id:<n>, account:<id>, project:<id>, or provider/email:<addr>",
93
+ }),
90
94
  "system-prompt": Flags.string({
91
95
  description: "System prompt (default: coding assistant prompt)",
92
96
  }),
@@ -181,6 +185,7 @@ export default class Index extends Command {
181
185
  `# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
182
186
  `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
183
187
  `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
188
+ `# Pin a stored credential for this session\n ${APP_NAME} --credential email:me@example.com`,
184
189
  `# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
185
190
  `# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
186
191
  `# Export a session file to HTML\n ${APP_NAME} --export ~/.skc/agent/sessions/--path--/session.jsonl`,
@@ -2,7 +2,7 @@
2
2
  * Configure Telegram notifications.
3
3
  */
4
4
  import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
5
- import { type NotifyAction, type NotifyCommandArgs, runNotifyCommand } from "../cli/notify-cli";
5
+ import { type NotifyAction, type NotifyCommandArgs, runNotifyCliCommand } from "../cli/notify-cli";
6
6
  import { initTheme } from "../modes/theme/theme";
7
7
 
8
8
  const ACTIONS: NotifyAction[] = ["setup", "status", "daemon-internal"];
@@ -56,6 +56,6 @@ export default class Notify extends Command {
56
56
  };
57
57
 
58
58
  if (action !== "daemon-internal") await initTheme();
59
- await runNotifyCommand(cmd);
59
+ await runNotifyCliCommand(cmd);
60
60
  }
61
61
  }
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@sayknow-cli/agent-core";
1
+ import { ThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import type { Api, Model } from "@sayknow-cli/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
@@ -43,7 +43,10 @@ export interface PrepareModelProfileActivationOptions {
43
43
  settings: Pick<Settings, "get">;
44
44
  profileName: string;
45
45
  }
46
-
46
+ export interface ApplyModelProfileActivationOptions {
47
+ persistDefault?: boolean;
48
+ thinkingLevelOverride?: ThinkingLevel;
49
+ }
47
50
  export interface PreparedModelProfileActivation {
48
51
  profileName: string;
49
52
  session: ModelProfileActivationSession & { setModelTemporary: AgentSession["setModelTemporary"] };
@@ -351,25 +354,31 @@ export async function prepareModelProfileActivation(
351
354
 
352
355
  export async function applyPreparedModelProfileActivation(
353
356
  prepared: PreparedModelProfileActivation,
354
- options: { persistDefault?: boolean } = {},
357
+ options: ApplyModelProfileActivationOptions = {},
355
358
  ): Promise<void> {
356
359
  const previousModel = prepared.previousModel;
357
360
  const previousThinkingLevel = prepared.previousThinkingLevel;
358
361
  const previousAgentModelOverrides = prepared.previousAgentModelOverrides;
359
362
  const previousModelRoles = prepared.previousModelRoles;
360
363
  const previousPersistedDefault = prepared.settings.get("modelProfile.default");
364
+ const previousDefaultThinkingLevel = prepared.settings.get("defaultThinkingLevel");
361
365
  const previousActiveModelProfile = prepared.previousActiveModelProfile;
362
366
  const previousSessionDefaultModel = prepared.previousSessionDefaultModel;
363
367
  let modelChanged = false;
364
368
  let overridesChanged = false;
365
369
  let defaultChanged = false;
366
370
  let modelRolesChanged = false;
371
+ let defaultThinkingChanged = false;
367
372
 
368
373
  try {
369
374
  if (prepared.defaultModel) {
370
- await prepared.session.setModelTemporary(prepared.defaultModel, prepared.defaultThinkingLevel, {
371
- persistAsSessionDefault: true,
372
- });
375
+ await prepared.session.setModelTemporary(
376
+ prepared.defaultModel,
377
+ options.thinkingLevelOverride ?? prepared.defaultThinkingLevel,
378
+ {
379
+ persistAsSessionDefault: true,
380
+ },
381
+ );
373
382
  modelChanged = true;
374
383
  }
375
384
  if (Object.keys(prepared.modelRoles).length > 0) {
@@ -386,6 +395,10 @@ export async function applyPreparedModelProfileActivation(
386
395
  if (options.persistDefault) {
387
396
  prepared.settings.set("modelRoles", {});
388
397
  prepared.settings.set("task.agentModelOverrides", {});
398
+ if (prepared.defaultThinkingLevel !== undefined && prepared.defaultThinkingLevel !== ThinkingLevel.Inherit) {
399
+ prepared.settings.set("defaultThinkingLevel", prepared.defaultThinkingLevel);
400
+ defaultThinkingChanged = true;
401
+ }
389
402
  prepared.settings.set("modelProfile.default", prepared.profileName);
390
403
  defaultChanged = true;
391
404
  await prepared.settings.flush();
@@ -396,6 +409,9 @@ export async function applyPreparedModelProfileActivation(
396
409
  prepared.settings.set("modelProfile.default", previousPersistedDefault);
397
410
  prepared.settings.set("modelRoles", previousModelRoles);
398
411
  prepared.settings.set("task.agentModelOverrides", previousAgentModelOverrides);
412
+ if (defaultThinkingChanged) {
413
+ prepared.settings.set("defaultThinkingLevel", previousDefaultThinkingLevel);
414
+ }
399
415
  }
400
416
  if (modelRolesChanged) {
401
417
  prepared.settings.override("modelRoles", previousModelRoles);
@@ -430,7 +446,7 @@ export async function applyPreparedModelProfileActivation(
430
446
 
431
447
  export async function activateModelProfile(
432
448
  options: PrepareModelProfileActivationOptions,
433
- applyOptions: { persistDefault?: boolean } = {},
449
+ applyOptions: ApplyModelProfileActivationOptions = {},
434
450
  ): Promise<void> {
435
451
  const prepared = await prepareModelProfileActivation(options);
436
452
  await applyPreparedModelProfileActivation(prepared, applyOptions);
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import {
4
4
  type Api,
5
5
  type AssistantMessageEventStream,
6
+ type AuthCredentialSelector,
6
7
  type CacheRetention,
7
8
  type Context,
8
9
  createModelManager,
@@ -83,7 +84,8 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
83
84
  };
84
85
 
85
86
  export const MODEL_ROLE_IDS: ModelRole[] = ["default"];
86
-
87
+ export const MODEL_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
88
+ export const MODEL_PROFILE_NAME_PATTERN_DESCRIPTION = "lowercase letters, numbers, dots, underscores, or hyphens";
87
89
  export type SkcModelAssignmentTargetId = "default" | "executor" | "architect" | "planner" | "critic";
88
90
 
89
91
  export interface SkcModelAssignmentTargetInfo extends ModelRoleInfo {
@@ -922,10 +924,13 @@ function resolveCustomModelReference(modelId: string): Model<Api> | undefined {
922
924
  }
923
925
 
924
926
  function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
925
- if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
927
+ if (model.contextWindow !== undefined) {
926
928
  return model;
927
929
  }
928
- return { ...model, contextWindow: 1_000_000 };
930
+ if (model.id === "gpt-5.4" && model.provider !== "github-copilot") {
931
+ return { ...model, contextWindow: 1_000_000 };
932
+ }
933
+ return model;
929
934
  }
930
935
 
931
936
  function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
@@ -1590,10 +1595,7 @@ export class ModelRegistry {
1590
1595
  const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1591
1596
  throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1592
1597
  }
1593
- await fs.mkdir(path.dirname(this.#modelsConfigFile.path()), { recursive: true });
1594
- await Bun.write(this.#modelsConfigFile.path(), Bun.YAML.stringify(checkedConfig.data, null, 2));
1595
- this.#modelsConfigFile.invalidate();
1596
- this.#reloadStaticModels();
1598
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1597
1599
  const modelMapping = { ...definition.model_mapping };
1598
1600
  const profile: ModelProfileDefinition = {
1599
1601
  name: normalizedName,
@@ -1604,6 +1606,84 @@ export class ModelRegistry {
1604
1606
  };
1605
1607
  return profile;
1606
1608
  }
1609
+
1610
+ async renameCustomModelProfile(name: string, displayName: string): Promise<ModelProfileDefinition> {
1611
+ const normalizedName = name.trim();
1612
+ const nextDisplayName = displayName.trim();
1613
+ if (!normalizedName) throw new Error("Profile name is required.");
1614
+ if (!nextDisplayName) throw new Error("Profile display name is required.");
1615
+ const { current, profile } = this.#loadCustomProfileForMutation(normalizedName, "rename");
1616
+ const nextProfiles = {
1617
+ ...(current.profiles ?? {}),
1618
+ [normalizedName]: {
1619
+ ...profile,
1620
+ display_name: nextDisplayName,
1621
+ },
1622
+ };
1623
+ const checkedConfig = ModelsConfigSchema.safeParse({ ...current, profiles: nextProfiles });
1624
+ if (!checkedConfig.success) {
1625
+ const first = checkedConfig.error.issues[0];
1626
+ const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1627
+ throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1628
+ }
1629
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1630
+ const modelMapping = { ...profile.model_mapping };
1631
+ return {
1632
+ name: normalizedName,
1633
+ displayName: nextDisplayName,
1634
+ requiredProviders: aggregateModelProfileRequiredProviders(profile.required_providers, { modelMapping }),
1635
+ modelMapping,
1636
+ source: "user",
1637
+ };
1638
+ }
1639
+
1640
+ async deleteCustomModelProfile(name: string): Promise<ModelProfileConfig> {
1641
+ const normalizedName = name.trim();
1642
+ if (!normalizedName) throw new Error("Profile name is required.");
1643
+ const { current, profile } = this.#loadCustomProfileForMutation(normalizedName, "delete");
1644
+ const nextProfiles = { ...(current.profiles ?? {}) };
1645
+ delete nextProfiles[normalizedName];
1646
+ const checkedConfig = ModelsConfigSchema.safeParse({
1647
+ ...current,
1648
+ profiles: Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
1649
+ });
1650
+ if (!checkedConfig.success) {
1651
+ const first = checkedConfig.error.issues[0];
1652
+ const where = first?.path.length ? `/${first.path.map(String).join("/")}` : "root";
1653
+ throw new Error(`Generated models config is invalid at ${where}: ${first?.message ?? "unknown schema error"}`);
1654
+ }
1655
+ await this.#writeCheckedModelsConfig(checkedConfig.data);
1656
+ return profile;
1657
+ }
1658
+
1659
+ #loadCustomProfileForMutation(
1660
+ normalizedName: string,
1661
+ action: "rename" | "delete",
1662
+ ): { current: ModelsConfig; profile: ModelProfileConfig } {
1663
+ const loaded = this.#modelsConfigFile.tryLoad();
1664
+ if (loaded.status === "error") {
1665
+ throw new Error(
1666
+ `Cannot ${action} custom model profile because ${this.#modelsConfigFile.path()} is invalid. Fix the existing config before modifying presets.`,
1667
+ );
1668
+ }
1669
+ const current = loaded.value ?? this.#modelsConfigFile.createDefault();
1670
+ const profile = current.profiles?.[normalizedName];
1671
+ if (!profile) {
1672
+ const existing = this.#modelProfiles.get(normalizedName);
1673
+ if (existing && existing.source !== "user") {
1674
+ throw new Error(`Cannot ${action} bundled model profile: ${normalizedName}.`);
1675
+ }
1676
+ throw new Error(`Custom model profile does not exist: ${normalizedName}.`);
1677
+ }
1678
+ return { current, profile };
1679
+ }
1680
+
1681
+ async #writeCheckedModelsConfig(config: ModelsConfig): Promise<void> {
1682
+ await fs.mkdir(path.dirname(this.#modelsConfigFile.path()), { recursive: true });
1683
+ await Bun.write(this.#modelsConfigFile.path(), Bun.YAML.stringify(config, null, 2));
1684
+ this.#modelsConfigFile.invalidate();
1685
+ this.#reloadStaticModels();
1686
+ }
1607
1687
  applyConfiguredModelBindings(targetSettings: Settings): void {
1608
1688
  this.#modelBindingsTargetSettings = targetSettings;
1609
1689
  this.#applyConfiguredModelBindingsToTarget();
@@ -2579,21 +2659,37 @@ export class ModelRegistry {
2579
2659
  /**
2580
2660
  * Get API key for a model.
2581
2661
  */
2582
- async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
2662
+ async getApiKey(
2663
+ model: Model<Api>,
2664
+ sessionId?: string,
2665
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2666
+ ): Promise<string | undefined> {
2583
2667
  if (this.#keylessProviders.has(model.provider) && !this.authStorage.hasAuth(model.provider)) {
2584
2668
  return kNoAuth;
2585
2669
  }
2586
- return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl, modelId: model.id });
2670
+ return this.authStorage.getApiKey(model.provider, sessionId, {
2671
+ baseUrl: model.baseUrl,
2672
+ modelId: model.id,
2673
+ credentialSelector: options.credentialSelector,
2674
+ });
2587
2675
  }
2588
2676
 
2589
2677
  /**
2590
2678
  * Get API key for a provider (e.g., "openai").
2591
2679
  */
2592
- async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
2680
+ async getApiKeyForProvider(
2681
+ provider: string,
2682
+ sessionId?: string,
2683
+ baseUrl?: string,
2684
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2685
+ ): Promise<string | undefined> {
2593
2686
  if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2594
2687
  return kNoAuth;
2595
2688
  }
2596
- return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
2689
+ return this.authStorage.getApiKey(provider, sessionId, {
2690
+ baseUrl,
2691
+ credentialSelector: options.credentialSelector,
2692
+ });
2597
2693
  }
2598
2694
 
2599
2695
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
@@ -3,15 +3,7 @@
3
3
  */
4
4
 
5
5
  import { ThinkingLevel } from "@sayknow-cli/agent-core";
6
- import {
7
- type Api,
8
- clampThinkingLevelForModel,
9
- DEFAULT_MODEL_PER_PROVIDER,
10
- type Effort,
11
- type KnownProvider,
12
- type Model,
13
- modelsAreEqual,
14
- } from "@sayknow-cli/ai";
6
+ import { type Api, DEFAULT_MODEL_PER_PROVIDER, type KnownProvider, type Model, modelsAreEqual } from "@sayknow-cli/ai";
15
7
  import { fuzzyMatch } from "@sayknow-cli/tui";
16
8
  import { logger } from "@sayknow-cli/utils";
17
9
  import chalk from "chalk";
@@ -1197,7 +1189,7 @@ export async function findInitialModel(options: {
1197
1189
  isContinuing: boolean;
1198
1190
  defaultProvider?: string;
1199
1191
  defaultModelId?: string;
1200
- defaultThinkingSelector?: Effort;
1192
+ defaultThinkingSelector?: ThinkingLevel;
1201
1193
  modelRegistry: InitialModelRegistry;
1202
1194
  }): Promise<InitialModelResult> {
1203
1195
  const {
@@ -1212,7 +1204,7 @@ export async function findInitialModel(options: {
1212
1204
  } = options;
1213
1205
 
1214
1206
  let model: Model<Api> | undefined;
1215
- let thinkingLevel: Effort | undefined;
1207
+ let thinkingLevel: ThinkingLevel | undefined;
1216
1208
 
1217
1209
  // 1. CLI args take priority
1218
1210
  if (cliProvider && cliModel) {
@@ -1233,10 +1225,7 @@ export async function findInitialModel(options: {
1233
1225
  : (scoped.thinkingLevel ?? defaultThinkingSelector);
1234
1226
  return {
1235
1227
  model: scoped.model,
1236
- thinkingLevel:
1237
- scopedThinkingSelector === ThinkingLevel.Off
1238
- ? ThinkingLevel.Off
1239
- : clampThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1228
+ thinkingLevel: resolveThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1240
1229
  fallbackMessage: undefined,
1241
1230
  };
1242
1231
  }
@@ -1246,7 +1235,7 @@ export async function findInitialModel(options: {
1246
1235
  const found = modelRegistry.find(defaultProvider, defaultModelId);
1247
1236
  if (found) {
1248
1237
  model = found;
1249
- thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
1238
+ thinkingLevel = resolveThinkingLevelForModel(found, defaultThinkingSelector);
1250
1239
  return { model, thinkingLevel, fallbackMessage: undefined };
1251
1240
  }
1252
1241
  }
@@ -5,6 +5,7 @@ import { EDIT_MODES } from "../utils/edit-mode";
5
5
  import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
6
6
 
7
7
  const THINKING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as readonly Effort[];
8
+ const DEFAULT_THINKING_LEVELS = ["off", ...THINKING_EFFORTS] as const;
8
9
 
9
10
  import {
10
11
  DEFAULT_DISABLED_EXTENSIONS,
@@ -829,13 +830,13 @@ export const SETTINGS_SCHEMA = {
829
830
  // Reasoning and prompts
830
831
  defaultThinkingLevel: {
831
832
  type: "enum",
832
- values: THINKING_EFFORTS,
833
+ values: DEFAULT_THINKING_LEVELS,
833
834
  default: THINKING_EFFORTS[3],
834
835
  ui: {
835
836
  tab: "model",
836
837
  label: "Thinking Level",
837
838
  description: "Reasoning depth for thinking-capable models",
838
- options: [...THINKING_EFFORTS.map(getThinkingLevelMetadata)],
839
+ options: [...DEFAULT_THINKING_LEVELS.map(getThinkingLevelMetadata)],
839
840
  },
840
841
  },
841
842
 
@@ -375,6 +375,25 @@ export class Settings {
375
375
  }
376
376
  }
377
377
 
378
+ /**
379
+ * Like {@link flush}, but rejects if the durable save fails instead of
380
+ * swallowing the error. Use where the caller must confirm persistence before
381
+ * reporting success (e.g. the Telegram `/rich` toggle). In-memory instances
382
+ * ({@link isolated}) short-circuit in {@link #saveNow} and never throw.
383
+ */
384
+ async flushOrThrow(): Promise<void> {
385
+ if (this.#saveTimer) {
386
+ clearTimeout(this.#saveTimer);
387
+ this.#saveTimer = undefined;
388
+ }
389
+ if (this.#savePromise) {
390
+ await this.#savePromise;
391
+ }
392
+ if (this.#modified.size > 0) {
393
+ await this.#saveNow({ throwOnError: true });
394
+ }
395
+ }
396
+
378
397
  async cloneForCwd(cwd: string): Promise<Settings> {
379
398
  const cloned = new Settings({
380
399
  cwd,
@@ -818,7 +837,7 @@ export class Settings {
818
837
  }, 100);
819
838
  }
820
839
 
821
- async #saveNow(): Promise<void> {
840
+ async #saveNow(options: { throwOnError?: boolean } = {}): Promise<void> {
822
841
  if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
823
842
 
824
843
  const configPath = this.#configPath;
@@ -847,6 +866,10 @@ export class Settings {
847
866
  for (const p of modifiedPaths) {
848
867
  this.#modified.add(p);
849
868
  }
869
+ if (options.throwOnError) {
870
+ this.#rebuildMerged();
871
+ throw error;
872
+ }
850
873
  }
851
874
 
852
875
  this.#rebuildMerged();