@cjhyy/code-shell-core 0.6.0-rc.6 → 0.6.0-rc.7

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.
@@ -51,6 +51,7 @@ export function modelEntriesFromConnections(connections, credentials, catalog) {
51
51
  const e = {
52
52
  key: inst.id,
53
53
  provider: clientProvider(entry),
54
+ providerKind: entry.adapterKind,
54
55
  model: resolved.model,
55
56
  baseUrl: resolved.baseUrl,
56
57
  needsKey: resolved.needsKey,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.6.0-rc.6";
6
+ export declare const VERSION = "0.6.0-rc.7";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  export { Engine, loadAgentDefinitionsForCwd } from "./engine/engine.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.6.0-rc.6";
6
+ export const VERSION = "0.6.0-rc.7";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -190,7 +190,7 @@ export const RULES = [
190
190
  // Most modern frontier models on OpenRouter are multimodal; flag the
191
191
  // big families explicitly. The catch-all below stays vision=false so
192
192
  // older/text-only routes don't get a green checkmark they don't earn.
193
- match: /^(?:openai\/(?:gpt-4o|gpt-5|o[1-9])|anthropic\/claude-(?:3|4|opus|sonnet|haiku)|google\/gemini-[2-9]|x-ai\/grok-[4-9]|qwen\/qwen-?vl|meta\/llama-(?:3\.2|4))/i,
193
+ match: /^~?(?:openai\/(?:gpt-4o|gpt-5|o[1-9])|anthropic\/claude-(?:3|4|opus|sonnet|haiku|fable)|google\/gemini-(?:[2-9]|pro)|x-ai\/grok-[4-9]|qwen\/qwen-?vl|meta\/llama-(?:3\.2|4))/i,
194
194
  capability: {
195
195
  supportsVision: true,
196
196
  reasoning: { kind: "openrouter-reasoning" },
@@ -21,6 +21,11 @@ export interface ModelEntry {
21
21
  provider: string;
22
22
  /** Full model path: "anthropic/claude-opus-4-6" */
23
23
  model: string;
24
+ /**
25
+ * Catalog/provider kind used by capability rules. This can differ from
26
+ * `provider`, which only selects the client protocol family.
27
+ */
28
+ providerKind?: string;
24
29
  baseUrl?: string;
25
30
  apiKey?: string;
26
31
  /** Whether the catalog template requires a key (default true; false = local/
@@ -221,6 +221,7 @@ export class ModelPool {
221
221
  return "anthropic";
222
222
  return "openai";
223
223
  };
224
+ const providerKind = entry.providerKind ?? fromCat?.kind;
224
225
  return {
225
226
  provider: entry.provider ||
226
227
  kindToClientProvider(fromCat?.kind) ||
@@ -250,7 +251,7 @@ export class ModelPool {
250
251
  ...(entry.reasoningSummary ? { reasoningSummary: entry.reasoningSummary } : {}),
251
252
  // Carry the catalog kind through so the capability layer can pick
252
253
  // per-(kind, model) request-shape rules.
253
- ...(fromCat?.kind ? { providerKind: fromCat.kind } : {}),
254
+ ...(providerKind ? { providerKind } : {}),
254
255
  ...(entry.extraBody && Object.keys(entry.extraBody).length > 0
255
256
  ? { extraBody: entry.extraBody }
256
257
  : {}),
@@ -8,6 +8,7 @@
8
8
  * ~/.code-shell/model-catalog.user.json) — no UI / adapter changes, as long as
9
9
  * its adapterKind points at an already-wired adapter.
10
10
  */
11
+ import { capabilitiesFor } from "../llm/capabilities/index.js";
11
12
  import { paramSpecsFromCapability } from "../llm/capabilities/param-specs.js";
12
13
  /**
13
14
  * Build a text model preset, projecting its params from the capability layer
@@ -15,11 +16,13 @@ import { paramSpecsFromCapability } from "../llm/capabilities/param-specs.js";
15
16
  * when the model has none, matching the "absent → no knobs" contract.
16
17
  */
17
18
  function textPreset(kind, value, label, ctx) {
19
+ const capability = capabilitiesFor(kind, value);
18
20
  const params = paramSpecsFromCapability(kind, value);
19
21
  return {
20
22
  value,
21
23
  ...(label ? { label } : {}),
22
24
  ...(ctx !== undefined ? { maxContextTokens: ctx } : {}),
25
+ supportsVision: capability.supportsVision,
23
26
  ...(params.length > 0 ? { params } : {}),
24
27
  };
25
28
  }
@@ -97,14 +100,16 @@ const ZHIPU_PARAMS = [
97
100
  ];
98
101
  /** Zhipu GLM preset — shared ZHIPU_PARAMS, with per-model context window. */
99
102
  function glmPreset(value, label, ctx) {
100
- return { value, label, maxContextTokens: ctx, params: ZHIPU_PARAMS };
103
+ return { value, label, maxContextTokens: ctx, supportsVision: false, params: ZHIPU_PARAMS };
101
104
  }
102
105
  /** OpenRouter preset with explicit params (no capability-layer projection). */
103
106
  function orPreset(value, label, params, ctx) {
107
+ const capability = capabilitiesFor("openrouter", value);
104
108
  return {
105
109
  value,
106
110
  label,
107
111
  ...(ctx !== undefined ? { maxContextTokens: ctx } : {}),
112
+ supportsVision: capability.supportsVision,
108
113
  ...(params && params.length > 0 ? { params } : {}),
109
114
  };
110
115
  }
@@ -49,6 +49,10 @@ export declare function registerPreset(preset: AgentPreset): void;
49
49
  /** List all available preset names (built-in + custom). */
50
50
  export declare function listPresetNames(): string[];
51
51
  export declare function resolveAgentPreset(name?: string): AgentPreset;
52
+ export interface BuildPresetSystemPromptOptions {
53
+ activeToolNames?: readonly string[];
54
+ platform?: NodeJS.Platform;
55
+ }
52
56
  /**
53
57
  * Build the full system prompt for a preset by loading and joining its sections.
54
58
  *
@@ -58,7 +62,7 @@ export declare function resolveAgentPreset(name?: string): AgentPreset;
58
62
  * (tools + usage instructions disappear together). Omit `activeToolNames` to
59
63
  * include all sections (e.g. when assembling a generic/preview prompt).
60
64
  */
61
- export declare function buildPresetSystemPrompt(preset: AgentPreset, activeToolNames?: readonly string[]): string;
65
+ export declare function buildPresetSystemPrompt(preset: AgentPreset, optionsOrActiveToolNames?: BuildPresetSystemPromptOptions | readonly string[]): string;
62
66
  export declare function resolveBuiltinToolNames(options?: {
63
67
  preset?: string;
64
68
  enabledBuiltinTools?: string[];
@@ -232,6 +232,19 @@ const BROWSER_SECTION_TOOLS = ["browser_observe", "browser_act", "browser_naviga
232
232
  const TOOL_GATED_SECTIONS = {
233
233
  browser: BROWSER_SECTION_TOOLS,
234
234
  };
235
+ function isActiveToolNamesList(value) {
236
+ return Array.isArray(value);
237
+ }
238
+ function platformShellGuidance(platform) {
239
+ if (platform !== "win32")
240
+ return "";
241
+ return [
242
+ "# Windows Shell Guidance",
243
+ "- On Windows, Bash uses Git Bash when it is available. Prefer Bash for ordinary shell commands, git operations, package-manager commands, tests, and POSIX-style command lines.",
244
+ "- Do not choose PowerShell merely because the OS is Windows. Use PowerShell only when the user explicitly asks for it or the task requires PowerShell-specific cmdlets, Windows APIs, registry access, or `.ps1` behavior.",
245
+ "- When writing Bash commands on Windows, use Git Bash paths such as `/d/github/project` instead of PowerShell-only syntax or raw `D:\\...` paths inside `cd` commands.",
246
+ ].join("\n");
247
+ }
235
248
  /**
236
249
  * Build the full system prompt for a preset by loading and joining its sections.
237
250
  *
@@ -241,8 +254,12 @@ const TOOL_GATED_SECTIONS = {
241
254
  * (tools + usage instructions disappear together). Omit `activeToolNames` to
242
255
  * include all sections (e.g. when assembling a generic/preview prompt).
243
256
  */
244
- export function buildPresetSystemPrompt(preset, activeToolNames) {
257
+ export function buildPresetSystemPrompt(preset, optionsOrActiveToolNames) {
245
258
  let sections = preset.promptSections;
259
+ const options = isActiveToolNamesList(optionsOrActiveToolNames)
260
+ ? { activeToolNames: optionsOrActiveToolNames }
261
+ : (optionsOrActiveToolNames ?? {});
262
+ const activeToolNames = options.activeToolNames;
246
263
  if (activeToolNames) {
247
264
  const active = new Set(activeToolNames);
248
265
  sections = sections.filter((s) => {
@@ -250,7 +267,9 @@ export function buildPresetSystemPrompt(preset, activeToolNames) {
250
267
  return !gate || gate.some((t) => active.has(t));
251
268
  });
252
269
  }
253
- return loadSections(sections);
270
+ return [loadSections(sections), platformShellGuidance(options.platform ?? process.platform)]
271
+ .filter(Boolean)
272
+ .join("\n\n");
254
273
  }
255
274
  export function resolveBuiltinToolNames(options) {
256
275
  const preset = resolveAgentPreset(options?.preset);
@@ -187,7 +187,7 @@ export class PromptComposer {
187
187
  cacheBreak: true,
188
188
  compute: () => {
189
189
  const preset = this.options.preset ?? resolveAgentPreset();
190
- return buildPresetSystemPrompt(preset, activeToolNames);
190
+ return buildPresetSystemPrompt(preset, { activeToolNames, platform: process.platform });
191
191
  },
192
192
  });
193
193
  // Skills listing is intentionally NOT a system section: it changes when a
@@ -31,7 +31,7 @@ When you encounter an obstacle, do not use destructive actions as a shortcut to
31
31
  - To search for files use Glob instead of find or ls
32
32
  - To search the content of files, use Grep instead of grep or rg
33
33
  - Reserve using the Bash exclusively for system commands and terminal operations that require shell execution.
34
- - Shell choice: when Bash is available, use it for ordinary shell commands, git operations, package-manager commands, test/build scripts, and POSIX-style command lines. On Windows, Bash uses Git Bash when it is available, so do not choose PowerShell merely because the OS is Windows. Use PowerShell only when the user explicitly asks for it or the task requires PowerShell-specific cmdlets, Windows APIs, registry access, or `.ps1` behavior.
34
+ - Shell choice: use Bash for ordinary shell commands, git operations, package-manager commands, test/build scripts, and POSIX-style command lines. Use PowerShell only when the user explicitly asks for it or the task requires PowerShell-specific cmdlets, Windows APIs, registry access, or `.ps1` behavior.
35
35
  - For long-lived processes that don't exit on their own — a dev server (`npm run dev`, `vite`), a watcher, a tunnel — call Bash with `run_in_background: true`. It returns a `shell_id` immediately instead of blocking until a timeout. Then use `BashOutput(shell_id)` to read its logs (e.g. to confirm it started or to see an error), `ListShells()` to see what's running, and `KillShell(shell_id)` to stop it. Never run such a command in the foreground — it will just block until it's killed. Plain one-shot commands (build, test, git) stay foreground.
36
36
  - Break down and manage multi-step work with the TodoWrite tool. Pass the complete todo list each call; rewrite it as items move pending → in_progress → completed.
37
37
  - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel.
@@ -997,12 +997,17 @@ export class AgentServer {
997
997
  break;
998
998
  }
999
999
  case "compact": {
1000
- if (!engine) {
1001
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for compact query"));
1000
+ const compactEngine = this.chatManager && typeof params.sessionId === "string"
1001
+ ? this.chatManager.get(params.sessionId)?.engine
1002
+ : engine;
1003
+ if (!compactEngine) {
1004
+ this.transport.send(createErrorResponse(req.id, this.chatManager && params.sessionId ? ErrorCodes.SessionClosed : ErrorCodes.InternalError, this.chatManager && params.sessionId
1005
+ ? `No such live session: ${params.sessionId}`
1006
+ : "No engine available for compact query"));
1002
1007
  return;
1003
1008
  }
1004
1009
  try {
1005
- const result = engine.forceCompact();
1010
+ const result = compactEngine.forceCompact();
1006
1011
  this.transport.send(createResponse(req.id, {
1007
1012
  type: "compact",
1008
1013
  data: result,
@@ -89,6 +89,7 @@ export class BackgroundShellManager {
89
89
  error: `Too many background shells for this session (max ${MAX_SHELLS_PER_SESSION}). KillShell some first.`,
90
90
  };
91
91
  }
92
+ const profileStartedAt = backgroundSpawnProfileEnabled() ? performance.now() : 0;
92
93
  const shell = opts.shell ?? defaultShellBinary();
93
94
  const { file, args } = resolveSpawnTarget(opts.command, {
94
95
  cwd: opts.cwd,
@@ -113,8 +114,10 @@ export class BackgroundShellManager {
113
114
  });
114
115
  }
115
116
  catch (err) {
117
+ logBackgroundSpawnProfile(file, args, elapsedProfileMs(profileStartedAt), "spawn_failed");
116
118
  return { ok: false, error: `Failed to spawn background shell: ${err.message}` };
117
119
  }
120
+ logBackgroundSpawnProfile(file, args, elapsedProfileMs(profileStartedAt), "started");
118
121
  if (child.pid === undefined) {
119
122
  return { ok: false, error: "Failed to spawn background shell: no pid" };
120
123
  }
@@ -493,3 +496,14 @@ export class BackgroundShellManager {
493
496
  }
494
497
  /** Process-local singleton, mirroring asyncAgentRegistry's lifetime contract. */
495
498
  export const backgroundShellManager = new BackgroundShellManager();
499
+ function backgroundSpawnProfileEnabled() {
500
+ return process.env.CODESHELL_SPAWN_PROFILE === "1";
501
+ }
502
+ function elapsedProfileMs(startedAt) {
503
+ return startedAt > 0 ? Math.round(performance.now() - startedAt) : undefined;
504
+ }
505
+ function logBackgroundSpawnProfile(file, args, elapsedMs, status) {
506
+ if (!backgroundSpawnProfileEnabled())
507
+ return;
508
+ console.error(`[spawn-profile] background shell=${JSON.stringify(file)} flag=${JSON.stringify(args[0] ?? "")} elapsedMs=${elapsedMs ?? "n/a"} status=${status}`);
509
+ }
@@ -50,6 +50,7 @@ export function safeSpawn(file, args, opts) {
50
50
  args,
51
51
  opts,
52
52
  cleanup: undefined,
53
+ resolveMs: undefined,
53
54
  });
54
55
  }
55
56
  /**
@@ -60,6 +61,7 @@ export function safeSpawn(file, args, opts) {
60
61
  * abort, timeout, pre-spawn-abort. Used by the Bash tool.
61
62
  */
62
63
  export function safeSpawnShell(command, opts) {
64
+ const resolveStartedAt = spawnProfileEnabled() ? performance.now() : 0;
63
65
  // Don't hardcode a POSIX default here — resolveSpawnTarget →
64
66
  // resolveShellInvocation picks the platform shell (cmd.exe on Windows,
65
67
  // $SHELL/bin/bash on POSIX) when opts.shell is omitted.
@@ -69,11 +71,12 @@ export function safeSpawnShell(command, opts) {
69
71
  shell,
70
72
  sandbox: opts.sandbox,
71
73
  });
72
- return runLifecycle({ file, args, opts, cleanup });
74
+ return runLifecycle({ file, args, opts, cleanup, resolveMs: elapsedMs(resolveStartedAt) });
73
75
  }
74
- function runLifecycle({ file, args, opts, cleanup }) {
76
+ function runLifecycle({ file, args, opts, cleanup, resolveMs }) {
75
77
  const maxBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
76
78
  const abortGrace = opts.ioDrainGraceMs ?? DEFAULT_IO_DRAIN_GRACE_MS;
79
+ const lifecycleStartedAt = spawnProfileEnabled() ? performance.now() : 0;
77
80
  // Pre-spawn abort: don't pay spawn cost.
78
81
  if (opts.signal?.aborted) {
79
82
  safeCleanup(cleanup);
@@ -85,6 +88,7 @@ function runLifecycle({ file, args, opts, cleanup }) {
85
88
  if (settled)
86
89
  return;
87
90
  settled = true;
91
+ logSpawnProfile(file, args, resolveMs, elapsedMs(lifecycleStartedAt), result.reason);
88
92
  // Always release backend-allocated resources, regardless of exit path.
89
93
  // cleanup is best-effort — see seatbelt backend for rationale.
90
94
  safeCleanup(cleanup);
@@ -221,6 +225,17 @@ function runLifecycle({ file, args, opts, cleanup }) {
221
225
  });
222
226
  });
223
227
  }
228
+ function spawnProfileEnabled() {
229
+ return process.env.CODESHELL_SPAWN_PROFILE === "1";
230
+ }
231
+ function elapsedMs(startedAt) {
232
+ return startedAt > 0 ? Math.round(performance.now() - startedAt) : undefined;
233
+ }
234
+ function logSpawnProfile(file, args, resolveMs, lifecycleMs, reason) {
235
+ if (!spawnProfileEnabled())
236
+ return;
237
+ console.error(`[spawn-profile] shell=${JSON.stringify(file)} flag=${JSON.stringify(args[0] ?? "")} resolveMs=${resolveMs ?? "n/a"} lifecycleMs=${lifecycleMs ?? "n/a"} reason=${reason}`);
238
+ }
224
239
  function safeCleanup(cleanup) {
225
240
  if (!cleanup)
226
241
  return;
@@ -72,9 +72,11 @@ export interface SpawnTarget {
72
72
  * in five places (bash.ts, safe-spawn.ts, background-shell.ts, worktree.ts):
73
73
  *
74
74
  * - POSIX: `<explicit ?? $SHELL ?? /bin/bash> -c "<command>"`
75
- * - Windows: `cmd.exe /c "<command>"` (the `-c` flag does not exist on cmd;
76
- * a bare `-c` would be taken as a filename and fail). PowerShell uses
77
- * `-Command`, but cmd.exe is the safe default present on every Windows.
75
+ * - Windows: `<explicit shell ?? Git Bash ?? PowerShell ?? ComSpec ?? cmd.exe>` with the
76
+ * shell-appropriate flag: Git Bash/POSIX shells use `-c`, PowerShell uses
77
+ * `-Command`, and cmd.exe uses `/c` (`-c` would be taken as a filename and
78
+ * fail/hang). Git Bash is preferred because the Bash tool receives POSIX
79
+ * syntax from the model.
78
80
  *
79
81
  * `$SHELL` is ignored on Windows — it is virtually never set there, and when
80
82
  * it is (e.g. a stray value from a Unix-y env) it points at a POSIX path that
@@ -88,8 +90,12 @@ export declare function resolveShellInvocation(command: string, shell?: string):
88
90
  export declare function resolveGitBash(): string | undefined;
89
91
  /** Reset the Git Bash probe cache. Test-only (platform is stubbed per test). */
90
92
  export declare function _resetGitBashCache(): void;
93
+ export declare function resolvePowerShell(): string | undefined;
94
+ /** Reset the PowerShell probe cache. Test-only (platform is stubbed per test). */
95
+ export declare function _resetPowerShellCache(): void;
91
96
  /** The platform's default interactive shell binary, for spawning a bare shell
92
- * (no `-c`/`/c` command). Windows → Git Bash if present, else ComSpec/cmd.exe;
97
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else PowerShell,
98
+ * else ComSpec/cmd.exe;
93
99
  * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
94
100
  * commands actually run (cmd.exe can't). */
95
101
  export declare function defaultShellBinary(shell?: string): string;
@@ -105,9 +105,11 @@ export function mergeShellEnv(base, projectEnv) {
105
105
  * in five places (bash.ts, safe-spawn.ts, background-shell.ts, worktree.ts):
106
106
  *
107
107
  * - POSIX: `<explicit ?? $SHELL ?? /bin/bash> -c "<command>"`
108
- * - Windows: `cmd.exe /c "<command>"` (the `-c` flag does not exist on cmd;
109
- * a bare `-c` would be taken as a filename and fail). PowerShell uses
110
- * `-Command`, but cmd.exe is the safe default present on every Windows.
108
+ * - Windows: `<explicit shell ?? Git Bash ?? PowerShell ?? ComSpec ?? cmd.exe>` with the
109
+ * shell-appropriate flag: Git Bash/POSIX shells use `-c`, PowerShell uses
110
+ * `-Command`, and cmd.exe uses `/c` (`-c` would be taken as a filename and
111
+ * fail/hang). Git Bash is preferred because the Bash tool receives POSIX
112
+ * syntax from the model.
111
113
  *
112
114
  * `$SHELL` is ignored on Windows — it is virtually never set there, and when
113
115
  * it is (e.g. a stray value from a Unix-y env) it points at a POSIX path that
@@ -116,7 +118,7 @@ export function mergeShellEnv(base, projectEnv) {
116
118
  */
117
119
  export function resolveShellInvocation(command, shell) {
118
120
  if (process.platform === "win32") {
119
- const file = shell ?? process.env.ComSpec ?? "cmd.exe";
121
+ const file = shell ?? defaultShellBinary();
120
122
  // Flag form depends on the shell: PowerShell → -Command; a POSIX shell such
121
123
  // as Git Bash's bash.exe / sh → -c (it does NOT understand cmd's /c); cmd.exe
122
124
  // (and cmd-like) → /c. Detecting bash/sh matters now that defaultShellBinary
@@ -139,7 +141,7 @@ export function resolveShellInvocation(command, shell) {
139
141
  * default is `cmd.exe`, which can't run any of that — so "Bash" was effectively
140
142
  * broken on Windows. Git for Windows (which most devs already have — we detect
141
143
  * it for repo ops anyway) ships a full bash at `<git>\bin\bash.exe`, so prefer
142
- * it. Only falls back to cmd.exe when Git Bash truly isn't present.
144
+ * it. Falls back to PowerShell before cmd.exe when Git Bash truly isn't present.
143
145
  *
144
146
  * Resolution order:
145
147
  * 1. CODE_SHELL_GIT_BASH_PATH env override (explicit user config wins).
@@ -183,15 +185,49 @@ export function resolveGitBash() {
183
185
  export function _resetGitBashCache() {
184
186
  gitBashCache = undefined;
185
187
  }
188
+ let powerShellCache;
189
+ export function resolvePowerShell() {
190
+ if (process.platform !== "win32")
191
+ return undefined;
192
+ if (powerShellCache !== undefined)
193
+ return powerShellCache ?? undefined;
194
+ const override = process.env.CODE_SHELL_POWERSHELL_PATH;
195
+ if (override && existsSync(override))
196
+ return (powerShellCache = override);
197
+ const candidates = [];
198
+ for (const exe of ["pwsh", "powershell"]) {
199
+ try {
200
+ const out = execFileSync("where", [exe], { encoding: "utf-8", timeout: 3000 });
201
+ const found = out.split(/\r?\n/).find((l) => l.trim().toLowerCase().endsWith(`${exe}.exe`));
202
+ if (found)
203
+ candidates.push(found.trim());
204
+ }
205
+ catch {
206
+ // Not on PATH; try the next shell / well-known locations.
207
+ }
208
+ }
209
+ const pf = process.env["ProgramFiles"] ?? "C:\\Program Files";
210
+ const systemRoot = process.env.SystemRoot ?? "C:\\Windows";
211
+ candidates.push(join(pf, "PowerShell", "7", "pwsh.exe"));
212
+ candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
213
+ const found = candidates.find((p) => existsSync(p));
214
+ return (powerShellCache = found ?? null) ?? undefined;
215
+ }
216
+ /** Reset the PowerShell probe cache. Test-only (platform is stubbed per test). */
217
+ export function _resetPowerShellCache() {
218
+ powerShellCache = undefined;
219
+ }
186
220
  /** The platform's default interactive shell binary, for spawning a bare shell
187
- * (no `-c`/`/c` command). Windows → Git Bash if present, else ComSpec/cmd.exe;
221
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else PowerShell,
222
+ * else ComSpec/cmd.exe;
188
223
  * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
189
224
  * commands actually run (cmd.exe can't). */
190
225
  export function defaultShellBinary(shell) {
191
226
  if (shell)
192
227
  return shell;
193
- if (process.platform === "win32")
194
- return resolveGitBash() ?? process.env.ComSpec ?? "cmd.exe";
228
+ if (process.platform === "win32") {
229
+ return resolveGitBash() ?? resolvePowerShell() ?? process.env.ComSpec ?? "cmd.exe";
230
+ }
195
231
  return process.env.SHELL ?? "/bin/bash";
196
232
  }
197
233
  /**
@@ -30,9 +30,8 @@ export const bashToolDef = {
30
30
  name: "Bash",
31
31
  description: "Execute a shell command and return its output. " +
32
32
  "Use for ordinary shell commands, system operations, git commands, " +
33
- "package-manager commands, running tests, installing packages, etc. " +
34
- "On Windows this tool prefers Git Bash when available, so prefer Bash over " +
35
- "PowerShell unless the task needs PowerShell-specific syntax or APIs.",
33
+ "package-manager commands, running tests, installing packages, etc. Prefer Bash " +
34
+ "over PowerShell unless the task needs PowerShell-specific syntax or APIs.",
36
35
  inputSchema: {
37
36
  type: "object",
38
37
  properties: {
@@ -10,11 +10,13 @@
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { userCatalogPath } from "../../model-catalog/index.js";
12
12
  import { saveCatalogEntry } from "../../model-catalog/save-entry.js";
13
+ import { catalogEntrySchema } from "../../model-catalog/types.js";
13
14
  export const editModelCatalogToolDef = {
14
15
  name: "EditModelCatalog",
15
16
  description: "Add or update a provider/model template in the user model catalog so it " +
16
- "appears in the connection page (text/image/video). Keyed by `id` a new id " +
17
- "adds, an existing id updates (in place). Backs up the file before writing and " +
17
+ "appears in the connection page (text/image/video). Keyed by `id`: a new id " +
18
+ "adds a provider, an existing id replaces that user-catalog entry. Backs up " +
19
+ "the file before writing and " +
18
20
  "validates the entry. Use after researching a model's real facts (id, context " +
19
21
  "window, supported params, modalities) — do NOT guess; verify against the " +
20
22
  "provider's official docs first (see the model-fact-finder skill). Does NOT set " +
@@ -31,7 +33,8 @@ export const editModelCatalogToolDef = {
31
33
  "maxContextTokens?, maxOutputTokens?, supportsVision?, params?[]}). Each param: " +
32
34
  "{name, label?, control (enum|number|toggle|text), options?[], min?, max?, default?, " +
33
35
  "doc?, wire?{field}}. Fill maxContextTokens with the model's REAL window; declare " +
34
- "params per-model (only what that model supports).",
36
+ "params per-model (only what that model supports). To keep a custom provider " +
37
+ "beside a built-in one, use a distinct id.",
35
38
  },
36
39
  },
37
40
  required: ["entry"],
@@ -42,11 +45,18 @@ export async function editModelCatalogTool(args) {
42
45
  if (!entry || typeof entry !== "object") {
43
46
  return "Error: `entry` (a CatalogEntry object) is required.";
44
47
  }
48
+ const parsed = catalogEntrySchema.safeParse(entry);
49
+ if (!parsed.success) {
50
+ return `Error: invalid catalog entry: ${parsed.error.issues.map((i) => i.message).join("; ")}`;
51
+ }
45
52
  const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
46
- const r = saveCatalogEntry(entry, { path: userCatalogPath(), stamp });
53
+ const r = saveCatalogEntry(parsed.data, {
54
+ path: userCatalogPath(),
55
+ stamp,
56
+ });
47
57
  if (!r.ok)
48
58
  return `Error: ${r.error}`;
49
- return summarizeWrite(entry, r.action ?? "added", r.backup);
59
+ return summarizeWrite(parsed.data, r.action ?? "added", r.backup);
50
60
  }
51
61
  /**
52
62
  * Build a structured completion summary so the user can SEE exactly what was
@@ -86,6 +86,7 @@ export const generateVideoToolDef = {
86
86
  let injectedProvider = null;
87
87
  export function __setVideoProviderForTests(p) {
88
88
  injectedProvider = p;
89
+ availCache.clear();
89
90
  }
90
91
  /**
91
92
  * Tool-visibility guard: GenerateVideo is hidden until a video provider is
@@ -97,6 +98,8 @@ export function __setVideoProviderForTests(p) {
97
98
  const availCache = new Map();
98
99
  const AVAIL_TTL_MS = 1000;
99
100
  export function isGenerateVideoAvailable(cwd = process.cwd(), nowMs = Date.now()) {
101
+ if (injectedProvider)
102
+ return true;
100
103
  const hit = availCache.get(cwd);
101
104
  if (hit && nowMs - hit.at < AVAIL_TTL_MS)
102
105
  return hit.value;
@@ -3,5 +3,14 @@
3
3
  */
4
4
  import type { ToolDefinition } from "../../types.js";
5
5
  import type { ToolContext } from "../context.js";
6
+ type ExecFileForGrep = (file: string, args: readonly string[], options: {
7
+ maxBuffer: number;
8
+ timeout: number;
9
+ }) => Promise<{
10
+ stdout: string;
11
+ stderr: string;
12
+ }>;
13
+ export declare function _setGrepExecFileForTest(fn?: ExecFileForGrep): void;
6
14
  export declare const grepToolDef: ToolDefinition;
7
15
  export declare function grepTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
16
+ export {};
@@ -2,9 +2,14 @@
2
2
  * Built-in Grep content search tool.
3
3
  */
4
4
  import { execFile } from "node:child_process";
5
+ import { readdir, readFile, stat } from "node:fs/promises";
5
6
  import { promisify } from "node:util";
6
- import { resolve, sep, isAbsolute } from "node:path";
7
+ import { basename, join, relative, resolve, sep, isAbsolute } from "node:path";
7
8
  const execFileAsync = promisify(execFile);
9
+ let execFileForTest = execFileAsync;
10
+ export function _setGrepExecFileForTest(fn) {
11
+ execFileForTest = fn ?? execFileAsync;
12
+ }
8
13
  /**
9
14
  * Strip the cwd prefix from each line so results display as relative
10
15
  * paths (e.g. "src/ui/App.tsx" instead of an absolute path). Lines that
@@ -76,10 +81,16 @@ export async function grepTool(args, ctx) {
76
81
  catch (grepErr) {
77
82
  if (isNoMatchExit(grepErr))
78
83
  return "No matches found.";
84
+ if (isCommandNotFound(rgErr) || isCommandNotFound(grepErr)) {
85
+ return await runNodeGrep(pattern, searchPath, fileGlob, context, maxResults, outputMode, caseInsensitive);
86
+ }
79
87
  return `Error in search: ${grepErr.message}`;
80
88
  }
81
89
  }
82
90
  }
91
+ function isCommandNotFound(err) {
92
+ return err?.code === "ENOENT";
93
+ }
83
94
  async function runRipgrep(pattern, path, fileGlob, context, maxResults, outputMode, caseInsensitive) {
84
95
  const args = ["--color=never"];
85
96
  if (caseInsensitive)
@@ -102,7 +113,7 @@ async function runRipgrep(pattern, path, fileGlob, context, maxResults, outputMo
102
113
  // Ignore common non-source dirs
103
114
  args.push("--glob", "!node_modules", "--glob", "!.git", "--glob", "!dist", "--glob", "!coverage");
104
115
  args.push("--", pattern, path);
105
- const { stdout } = await execFileAsync("rg", args, {
116
+ const { stdout } = await execFileForTest("rg", args, {
106
117
  maxBuffer: 10 * 1024 * 1024,
107
118
  timeout: 30_000,
108
119
  });
@@ -139,7 +150,7 @@ async function runGrep(pattern, path, fileGlob, context, maxResults, outputMode,
139
150
  args.push("-E", pattern);
140
151
  args.push("--exclude-dir=node_modules", "--exclude-dir=.git", "--exclude-dir=dist");
141
152
  args.push(path);
142
- const { stdout } = await execFileAsync("grep", args, {
153
+ const { stdout } = await execFileForTest("grep", args, {
143
154
  maxBuffer: 10 * 1024 * 1024,
144
155
  timeout: 30_000,
145
156
  });
@@ -152,3 +163,89 @@ async function runGrep(pattern, path, fileGlob, context, maxResults, outputMode,
152
163
  }
153
164
  return result;
154
165
  }
166
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "coverage"]);
167
+ async function runNodeGrep(pattern, path, fileGlob, context, maxResults, outputMode, caseInsensitive) {
168
+ let regex;
169
+ try {
170
+ regex = new RegExp(pattern, caseInsensitive ? "i" : "");
171
+ }
172
+ catch (err) {
173
+ return `Error in search: ${err.message}`;
174
+ }
175
+ const matches = [];
176
+ await walkTextFiles(path, fileGlob, async (file) => {
177
+ if (matches.length >= maxResults)
178
+ return;
179
+ let text;
180
+ try {
181
+ text = await readFile(file, "utf8");
182
+ }
183
+ catch {
184
+ return;
185
+ }
186
+ const lines = text.split(/\r?\n/);
187
+ const matchingLines = [];
188
+ for (let i = 0; i < lines.length; i++) {
189
+ regex.lastIndex = 0;
190
+ if (regex.test(lines[i]))
191
+ matchingLines.push(i);
192
+ }
193
+ if (matchingLines.length === 0)
194
+ return;
195
+ const rel = relative(path, file) || basename(file);
196
+ if (outputMode === "files_with_matches") {
197
+ matches.push(rel);
198
+ }
199
+ else if (outputMode === "count") {
200
+ matches.push(`${rel}:${matchingLines.length}`);
201
+ }
202
+ else {
203
+ const emitted = new Set();
204
+ for (const lineIndex of matchingLines) {
205
+ const start = Math.max(0, lineIndex - context);
206
+ const end = Math.min(lines.length - 1, lineIndex + context);
207
+ for (let i = start; i <= end; i++) {
208
+ if (emitted.has(i))
209
+ continue;
210
+ emitted.add(i);
211
+ matches.push(`${rel}:${i + 1}:${lines[i]}`);
212
+ if (matches.length >= maxResults)
213
+ return;
214
+ }
215
+ }
216
+ }
217
+ });
218
+ if (matches.length === 0)
219
+ return "No matches found.";
220
+ if (matches.length > 200)
221
+ return matches.slice(0, 200).join("\n") + `\n\n... ${matches.length - 200} more results`;
222
+ return matches.join("\n");
223
+ }
224
+ async function walkTextFiles(root, fileGlob, visit) {
225
+ const info = await stat(root).catch(() => null);
226
+ if (!info)
227
+ return;
228
+ if (info.isFile()) {
229
+ if (!fileGlob || matchesSimpleGlob(basename(root), fileGlob))
230
+ await visit(root);
231
+ return;
232
+ }
233
+ if (!info.isDirectory())
234
+ return;
235
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
236
+ for (const entry of entries) {
237
+ if (entry.isDirectory()) {
238
+ if (IGNORED_DIRS.has(entry.name))
239
+ continue;
240
+ await walkTextFiles(join(root, entry.name), fileGlob, visit);
241
+ }
242
+ else if (entry.isFile()) {
243
+ if (!fileGlob || matchesSimpleGlob(entry.name, fileGlob))
244
+ await visit(join(root, entry.name));
245
+ }
246
+ }
247
+ }
248
+ function matchesSimpleGlob(name, glob) {
249
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
250
+ return new RegExp(`^${escaped}$`).test(name);
251
+ }
@@ -10,8 +10,8 @@ export const powershellToolDef = {
10
10
  name: "PowerShell",
11
11
  description: "Execute PowerShell commands. Use only when the user explicitly asks for PowerShell " +
12
12
  "or the task requires PowerShell-specific cmdlets, Windows APIs, registry access, " +
13
- "or .ps1 behavior. For ordinary shell/git/package/test commands, prefer Bash " +
14
- "(Git Bash on Windows when available).",
13
+ "or .ps1 behavior. Do NOT use for ordinary file, git, package-manager, test, " +
14
+ "or POSIX-style shell commands; use Bash for those.",
15
15
  inputSchema: {
16
16
  type: "object",
17
17
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.6.0-rc.6",
3
+ "version": "0.6.0-rc.7",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",