diff --git a/.gitignore b/.gitignore index f080d30177d..64918395857 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ target # Local dev files opencode-dev +UPCOMING_CHANGELOG.md logs/ *.bun-build diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 271e7eba186..f0ff1e422de 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -2,22 +2,43 @@ model: opencode/kimi-k2.5 --- -create UPCOMING_CHANGELOG.md - -it should have sections - -``` -## TUI - -## Desktop - -## Core - -## Misc -``` - -fetch the latest github release for this repository to determine the last release version. - -find each PR that was merged since the last release - -for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section. +Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. +If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely. +Do not preserve, merge, or reuse text from the existing file. + +Any command arguments are passed directly to `bun script/changelog.ts`. +Use `--from` / `-f` and `--to` / `-t` to preview a specific release range. + +The input already contains the exact commit range since the last non-draft release. +The commits are already filtered to the release-relevant packages and grouped into +the release sections. Do not fetch GitHub releases, PRs, or build your own commit list. +The input may also include a `## Community Contributors Input` section. + +Before writing any entry you keep, inspect the real diff with +`git show --stat --format='' ` or `git show --format='' ` so the +summary reflects the actual user-facing change and not just the commit message. +Do not use `git log` or author metadata when deciding attribution. + +Rules: + +- Write the final file with sections in this order: + `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` +- Only include sections that have at least one notable entry +- Keep one bullet per commit you keep +- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing +- Start each bullet with a capital letter +- Prefer what changed for users over what code changed internally +- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)` +- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input +- If an input bullet has no `(@username)` suffix, do not add one +- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses +- If no notable entries remain and there is no contributor block, write exactly `No notable changes.` +- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block +- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim +- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block +- Do not derive the thank-you section from the main summary bullets +- Do not include the heading `## Community Contributors Input` in the final file + +## Changelog Input + +!`bun script/changelog.ts $ARGUMENTS` diff --git a/bun.lock b/bun.lock index 5120c6b920c..d62d7ae7b00 100644 --- a/bun.lock +++ b/bun.lock @@ -421,6 +421,7 @@ "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", + "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", "vscode-jsonrpc": "8.2.1", @@ -593,9 +594,10 @@ }, }, "trustedDependencies": [ - "electron", "esbuild", + "tree-sitter-powershell", "protobufjs", + "electron", "web-tree-sitter", "tree-sitter-bash", ], @@ -4249,6 +4251,8 @@ "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], + "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], diff --git a/flake.nix b/flake.nix index ce2fa1a5a0f..13293523aa3 100644 --- a/flake.nix +++ b/flake.nix @@ -155,6 +155,7 @@ [ bun nodejs_20 + python3 pkg-config openssl git diff --git a/package.json b/package.json index 7d98c631ad0..8ba75be68ea 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "protobufjs", "tree-sitter", "tree-sitter-bash", + "tree-sitter-powershell", "web-tree-sitter", "electron" ], diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e5ca0038a3e..694addfaeec 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -577,7 +577,8 @@ export const dict = { "settings.general.row.appearance.title": "Erscheinungsbild", "settings.general.row.appearance.description": "Anpassen, wie Kilo auf Ihrem Gerät aussieht", "settings.general.row.colorScheme.title": "Farbschema", - "settings.general.row.colorScheme.description": "Wählen Sie, ob Kilo dem System-, hellen oder dunklen Thema folgt", + "settings.general.row.colorScheme.description": + "Wählen Sie, ob Kilo dem System-, hellen oder dunklen Thema folgt", "settings.general.row.theme.title": "Thema", "settings.general.row.theme.description": "Das Thema von Kilo anpassen.", "settings.general.row.font.title": "Code-Schriftart", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 6204ce037a3..2bd6ea940e6 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -398,7 +398,8 @@ export const dict = { "toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session", "toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}", "toast.update.title": "Mise à jour disponible", - "toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", + "toast.update.description": + "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", "toast.update.action.installRestart": "Installer et redémarrer", "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", @@ -548,7 +549,8 @@ export const dict = { "sidebar.workspaces.enable": "Activer les espaces de travail", "sidebar.workspaces.disable": "Désactiver les espaces de travail", "sidebar.gettingStarted.title": "Commencer", - "sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + "sidebar.gettingStarted.line1": + "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", "sidebar.gettingStarted.line2": "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0c22707e639..4a6bffdcbb5 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -149,7 +149,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง", "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.auto.visit.link": "ลิงก์นี้", - "provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", + "provider.connect.oauth.auto.visit.suffix": + " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน", "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว", "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a49256cc13f..f6abb762f5e 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -143,7 +143,8 @@ export const dict = { "provider.connect.opencodeZen.visit.prefix": "", "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.", - "provider.connect.oauth.code.visit.prefix": "Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ", + "provider.connect.oauth.code.visit.prefix": + "Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ", "provider.connect.oauth.code.visit.link": "bu bağlantıya", "provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.", "provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu", @@ -638,7 +639,8 @@ export const dict = { "settings.general.row.appearance.title": "Görünüm", "settings.general.row.appearance.description": "Kilo'un cihazınızdaki görünümünü özelleştirin", "settings.general.row.colorScheme.title": "Renk şeması", - "settings.general.row.colorScheme.description": "Kilo'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin", + "settings.general.row.colorScheme.description": + "Kilo'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin", "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Kilo'un temasını özelleştirin.", "settings.general.row.font.title": "Kod Yazı Tipi", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b405880a0c3..dcd680c9b93 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -150,7 +150,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "授權碼無效", "provider.connect.oauth.auto.visit.prefix": "造訪 ", "provider.connect.oauth.auto.visit.link": "此連結", - "provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", + "provider.connect.oauth.auto.visit.suffix": + " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "確認碼", "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 98ad25cb245..b48d9daa74e 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -43,7 +43,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
/.opencode`) is covered by regression tests. - `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call. - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors. +- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`. +- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced. - Without `--force`, an already-configured npm package name is a no-op. - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. - Tuple targets in `oc-plugin` provide default options written into config. @@ -164,7 +166,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` - `api.command.register(cb)` / `api.command.trigger(value)` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` -- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog` +- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog` - `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` @@ -210,6 +212,7 @@ Command behavior: - `ui.Dialog` is the base dialog wrapper. - `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components. +- `ui.Prompt` renders the same prompt component used by the host app. - `ui.toast(...)` shows a toast. - `ui.dialog` exposes the host dialog stack: - `replace(render, onClose?)` @@ -277,6 +280,7 @@ Current host slot names: - `app` - `home_logo` +- `home_prompt` with props `{ workspace_id? }` - `home_bottom` - `sidebar_title` with props `{ session_id, title, share_url? }` - `sidebar_content` with props `{ session_id }` @@ -289,7 +293,7 @@ Slot notes: - `api.slots.register(plugin)` does not return an unregister function. - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. - Plugin-provided `id` is not allowed. -- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode. - Plugins cannot define new slot names in this branch. ### Plugin control and lifecycle diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6a124a4f3a3..c8cdf45964a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -269,13 +269,6 @@ export const RunCommand = cmd({ default: "default", describe: "format: default (formatted) or json (raw JSON events)", }) - // kilocode_change start - auto approve all permissions - .option("auto", { - type: "boolean", - describe: "auto-approve all permissions (for autonomous/pipeline usage)", - default: false, - }) - // kilocode_change end .option("file", { alias: ["f"], type: "string", @@ -307,6 +300,13 @@ export const RunCommand = cmd({ type: "string", describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", }) + // kilocode_change start - auto approve all permissions + .option("auto", { + type: "boolean", + describe: "auto-approve all permissions (for autonomous/pipeline usage)", + default: false, + }) + // kilocode_change end .option("thinking", { type: "boolean", describe: "show thinking blocks", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6783a7019cd..be5cb4d6633 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -593,10 +593,22 @@ function App(props: { onSnapshot?: () => Promise }) { }, }, { - title: "Switch model variant", + title: "Variant cycle", value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + onSelect: () => { + local.model.variant.cycle() + }, + }, + { + title: "Switch model variant", + value: "variant.list", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slash: { + name: "variants", + }, onSelect: () => { dialog.replace(() => ) }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 45f09c5b470..a1eff0516d8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -160,7 +160,13 @@ export function DialogModel(props: { providerID?: string }) { function onSelect(providerID: string, modelID: string) { local.model.set({ providerID, modelID }, { recent: true }) - if (local.model.variant.list().length > 0) { + const list = local.model.variant.list() + const cur = local.model.variant.selected() + if (cur === "default" || (cur && list.includes(cur))) { + dialog.clear() + return + } + if (list.length > 0) { dialog.replace(() => ) return } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx index 872092d23e2..28ee1b28250 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx @@ -8,21 +8,31 @@ export function DialogVariant() { const dialog = useDialog() const options = createMemo(() => { - return local.model.variant.list().map((variant) => ({ - value: variant, - title: variant, - onSelect: () => { - dialog.clear() - local.model.variant.set(variant) + return [ + { + value: "default", + title: "Default", + onSelect: () => { + dialog.clear() + local.model.variant.set(undefined) + }, }, - })) + ...local.model.variant.list().map((variant) => ({ + value: variant, + title: variant, + onSelect: () => { + dialog.clear() + local.model.variant.set(variant) + }, + })), + ] }) return ( options={options()} title={"Select variant"} - current={local.model.variant.current()} + current={local.model.variant.selected()} flat={true} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d5c503ea796..895aad8d28e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -344,12 +344,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) }, variant: { - current() { + selected() { const m = currentModel() if (!m) return undefined const key = `${m.providerID}/${m.modelID}` return modelStore.variant[key] }, + current() { + const v = this.selected() + if (!v) return undefined + if (!this.list().includes(v)) return undefined + return v + }, list() { const m = currentModel() if (!m) return [] @@ -362,7 +368,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const m = currentModel() if (!m) return const key = `${m.providerID}/${m.modelID}` - setModelStore("variant", key, value) + setModelStore("variant", key, value ?? "default") save() }, cycle() { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index fb5fc6f2373..487a47b1a5e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { }} initialValue={input()} placeholder="Type your own answer" + placeholderColor={theme.textMuted} minHeight={1} maxHeight={6} textColor={theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index d29fe05ee90..64bd4fbb0f6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { ref={(val: TextareaRenderable) => (textarea = val)} initialValue={props.defaultFilename} placeholder="Enter filename" + placeholderColor={theme.textMuted} textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index cb1b8257ab8..370fc54bd88 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) { ref={(val: TextareaRenderable) => (textarea = val)} initialValue={props.value} placeholder={props.placeholder ?? "Enter text"} + placeholderColor={theme.textMuted} textColor={props.busy ? theme.textMuted : theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c0..34c6ee8787f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -260,6 +260,7 @@ export function DialogSelect(props: DialogSelectProps) { }, 1) }} placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} /> diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/migrate-tui-config.ts index 5122958c902..0d80926a5c0 100644 --- a/packages/opencode/src/config/migrate-tui-config.ts +++ b/packages/opencode/src/config/migrate-tui-config.ts @@ -144,7 +144,7 @@ async function opencodeFiles(input: { directories: string[]; managed: string }) files.push(...ConfigPaths.fileInDirectory(dir, "kilo")) } if (Flag.KILO_CONFIG) files.push(Flag.KILO_CONFIG) - files.push(...ConfigPaths.fileInDirectory(input.managed, "kilo")) + files.push(...ConfigPaths.fileInDirectory(input.managed, "kilo")) // kilocode_change const existing = await Promise.all( unique(files).map(async (file) => { diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 9640a662bd8..8c0a6ee2744 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -94,6 +94,13 @@ function pluginSpec(item: unknown) { return item[0] } +function pluginList(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return + const item = data as { plugin?: unknown } + if (!Array.isArray(item.plugin)) return + return item.plugin +} + function parseTarget(item: unknown): Target | undefined { if (item === "server" || item === "tui") return { kind: item } if (!Array.isArray(item)) return @@ -118,9 +125,28 @@ function parseTargets(raw: unknown) { return [...map.values()] } -function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } { +function patch(text: string, path: Array, value: unknown, insert = false) { + return applyEdits( + text, + modify(text, path, value, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + }, + isArrayInsertion: insert, + }), + ) +} + +function patchPluginList( + text: string, + list: unknown[] | undefined, + spec: string, + next: unknown, + force = false, +): { mode: Mode; text: string } { const pkg = parsePluginSpecifier(spec).pkg - const rows = list.map((item, i) => ({ + const rows = (list ?? []).map((item, i) => ({ item, i, spec: pluginSpec(item), @@ -133,16 +159,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f }) if (!dup.length) { + if (!list) { + return { + mode: "add", + text: patch(text, ["plugin"], [next]), + } + } return { mode: "add", - list: [...list, next], + text: patch(text, ["plugin", list.length], next, true), } } if (!force) { return { mode: "noop", - list, + text, } } @@ -150,29 +182,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f if (!keep) { return { mode: "noop", - list, + text, } } if (dup.length === 1 && keep.spec === spec) { return { mode: "noop", - list, + text, } } - const idx = new Set(dup.map((item) => item.i)) + let out = text + if (typeof keep.item === "string") { + out = patch(out, ["plugin", keep.i], next) + } + if (Array.isArray(keep.item) && typeof keep.item[0] === "string") { + out = patch(out, ["plugin", keep.i, 0], spec) + } + + const del = dup + .map((item) => item.i) + .filter((i) => i !== keep.i) + .sort((a, b) => b - a) + + for (const i of del) { + out = patch(out, ["plugin", i], undefined) + } + return { mode: "replace", - list: rows.flatMap((row) => { - if (!idx.has(row.i)) return [row.item] - if (row.i !== keep.i) return [] - if (typeof row.item === "string") return [next] - if (Array.isArray(row.item) && typeof row.item[0] === "string") { - return [[spec, ...row.item.slice(1)]] - } - return [row.item] - }), + text: out, } } @@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const list: unknown[] = - data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : [] + const list = pluginList(data) const item = target.opts ? [spec, target.opts] : spec - const out = patchPluginList(list, spec, item, force) + const out = patchPluginList(text, list, spec, item, force) if (out.mode === "noop") { return { ok: true, @@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea } } - const edits = modify(text, ["plugin"], out.list, { - formattingOptions: { - tabSize: 2, - insertSpaces: true, - }, - }) - const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error) + const write = await dep.write(cfg, out.text).catch((error: unknown) => error) if (write instanceof Error) { return { ok: false, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index e98e2ab3b87..10b10eefd15 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -176,7 +176,7 @@ export namespace Pty { const id = PtyID.ascending() const command = input.command || Shell.preferred() const args = input.args || [] - if (command.endsWith("sh")) { + if (Shell.login(command)) { args.push("-l") } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 223e71639cc..69759c0d96d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -176,6 +176,7 @@ export namespace SessionCompaction { const defaultPrompt = `Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. The summary that you construct will be used so that another agent can read it and continue the work. +Do not call any tools. Respond only with the summary text. When constructing the summary, try to stick to this template: --- diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 9f7464f0fe0..38513e0c1d9 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -13,7 +13,7 @@ const log = Log.create({ service: "instruction" }) const FILES = [ "AGENTS.md", - "CLAUDE.md", + ...(Flag.KILO_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4d3772f6025..0fab7586591 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -217,11 +217,19 @@ export namespace LLM { input.model.providerID.toLowerCase().includes("litellm") || input.model.api.id.toLowerCase().includes("litellm") + // LiteLLM/Bedrock rejects requests where the message history contains tool + // calls but no tools param is present. When there are no active tools (e.g. + // during compaction), inject a stub tool to satisfy the validation requirement. + // The stub description explicitly tells the model not to call it. if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { tools["_noop"] = tool({ - description: - "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed", - inputSchema: jsonSchema({ type: "object", properties: {} }), + description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + inputSchema: jsonSchema({ + type: "object", + properties: { + reason: { type: "string", description: "Unused" }, + }, + }), execute: async () => ({ output: "", title: "", metadata: {} }), }) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 59022f8e707..1a264161d2f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1685,9 +1685,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } await Session.updatePart(part) const shell = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) - ).toLowerCase() + const shellName = Shell.name(shell) const invocations: Record = { nu: { diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 60f26ebfe2f..9b4aea42393 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -9,6 +9,10 @@ import { setTimeout as sleep } from "node:timers/promises" const SIGKILL_TIMEOUT_MS = 200 export namespace Shell { + const BLACKLIST = new Set(["fish", "nu"]) + const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"]) + const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"]) + export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { const pid = proc.pid if (!pid || opts?.exited?.()) return @@ -39,18 +43,46 @@ export namespace Shell { } } } - const BLACKLIST = new Set(["fish", "nu"]) + + function full(file: string) { + if (process.platform !== "win32") return file + const shell = Filesystem.windowsPath(file) + if (path.win32.dirname(shell) !== ".") { + if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell + return shell + } + return Bun.which(shell) || shell + } + + function pick() { + const pwsh = Bun.which("pwsh") + if (pwsh) return pwsh + const powershell = Bun.which("powershell") + if (powershell) return powershell + } + + function select(file: string | undefined, opts?: { acceptable?: boolean }) { + if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file) + if (process.platform === "win32") { + const shell = pick() + if (shell) return shell + } + return fallback() + } + + export function gitbash() { + if (process.platform !== "win32") return + if (Flag.KILO_GIT_BASH_PATH) return Flag.KILO_GIT_BASH_PATH + const git = which("git") + if (!git) return + const file = path.join(git, "..", "..", "bin", "bash.exe") + if (Filesystem.stat(file)?.size) return file + } function fallback() { if (process.platform === "win32") { - if (Flag.KILO_GIT_BASH_PATH) return Flag.KILO_GIT_BASH_PATH - const git = which("git") - if (git) { - // git.exe is typically at: C:\Program Files\Git\cmd\git.exe - // bash.exe is at: C:\Program Files\Git\bin\bash.exe - const bash = path.join(git, "..", "..", "bin", "bash.exe") - if (Filesystem.stat(bash)?.size) return bash - } + const file = gitbash() + if (file) return file return process.env.COMSPEC || "cmd.exe" } if (process.platform === "darwin") return "/bin/zsh" @@ -59,15 +91,20 @@ export namespace Shell { return "/bin/sh" } - export const preferred = lazy(() => { - const s = process.env.SHELL - if (s) return s - return fallback() - }) + export function name(file: string) { + if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase() + return path.basename(file).toLowerCase() + } - export const acceptable = lazy(() => { - const s = process.env.SHELL - if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s - return fallback() - }) + export function login(file: string) { + return LOGIN.has(name(file)) + } + + export function posix(file: string) { + return POSIX.has(name(file)) + } + + export const preferred = lazy(() => select(process.env.SHELL)) + + export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true })) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ca5ea64d6a1..2d85f0e9dcd 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,27 +1,63 @@ import z from "zod" +import os from "os" import { spawn } from "child_process" -import { StringDecoder } from "string_decoder" // kilocode_change - fix UTF-8 multi-byte split import { Tool } from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import { Log } from "../util/log" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" -import { Language } from "web-tree-sitter" -import fs from "fs/promises" +import { Language, type Node } from "web-tree-sitter" import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" import { fileURLToPath } from "url" -import { Flag } from "@/flag/flag.ts" +import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { BashHierarchy } from "@/kilocode/bash-hierarchy" // kilocode_change import { Truncate } from "./truncate" import { Plugin } from "@/plugin" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 +const PS = new Set(["powershell", "pwsh"]) +const CWD = new Set(["cd", "push-location", "set-location"]) +const FILES = new Set([ + ...CWD, + "rm", + "cp", + "mv", + "mkdir", + "touch", + "chmod", + "chown", + "cat", + // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir + // already hit the entries above, and alias normalization should happen in one + // place later so we do not risk double-prompting. + "get-content", + "set-content", + "add-content", + "copy-item", + "move-item", + "remove-item", + "new-item", + "rename-item", +]) +const FLAGS = new Set(["-destination", "-literalpath", "-path"]) +const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) + +type Part = { + type: string + text: string +} + +type Scan = { + dirs: Set + patterns: Set + always: Set +} export const log = Log.create({ service: "bash-tool" }) @@ -32,6 +68,350 @@ const resolveWasm = (asset: string) => { return fileURLToPath(url) } +function parts(node: Node) { + const out: Part[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if (child.type === "command_elements") { + for (let j = 0; j < child.childCount; j++) { + const item = child.child(j) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue + out.push({ type: item.type, text: item.text }) + } + continue + } + if ( + child.type !== "command_name" && + child.type !== "command_name_expr" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) { + continue + } + out.push({ type: child.type, text: child.text }) + } + return out +} + +function source(node: Node) { + return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() +} + +function commands(node: Node) { + return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) +} + +function unquote(text: string) { + if (text.length < 2) return text + const first = text[0] + const last = text[text.length - 1] + if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) + return text +} + +function home(text: string) { + if (text === "~") return os.homedir() + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) + return text +} + +function envValue(key: string) { + if (process.platform !== "win32") return process.env[key] + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} + +function auto(key: string, cwd: string, shell: string) { + const name = key.toUpperCase() + if (name === "HOME") return os.homedir() + if (name === "PWD") return cwd + if (name === "PSHOME") return path.dirname(shell) +} + +function expand(text: string, cwd: string, shell: string) { + const out = unquote(text) + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") + return home(out) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const prefix = text.match(/^([A-Za-z]+):(.*)$/) + if (!prefix) return text + if (prefix[1].length === 1) return text + return +} + +function dynamic(text: string, ps: boolean) { + if (text.startsWith("(") || text.startsWith("@(")) return true + if (text.includes("$(") || text.includes("${") || text.includes("`")) return true + if (ps) return /\$(?!env:)/i.test(text) + return text.includes("$") +} + +function prefix(text: string) { + const match = /[?*\[]/.exec(text) + if (!match) return text + if (match.index === 0) return + return text.slice(0, match.index) +} + +async function cygpath(shell: string, text: string) { + const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) + if (out.code !== 0) return + const file = out.text.trim() + if (!file) return + return Filesystem.normalizePath(file) +} + +async function resolvePath(text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { + const file = await cygpath(shell, text) + if (file) return file + } + return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) + } + return path.resolve(root, text) +} + +async function argPath(arg: string, cwd: string, ps: boolean, shell: string) { + const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, ps)) return + const next = ps ? provider(file) : file + if (!next) return + return resolvePath(next, cwd, shell) +} + +function pathArgs(list: Part[], ps: boolean) { + if (!ps) { + return list + .slice(1) + .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .map((item) => item.text) + } + + const out: string[] = [] + let want = false + for (const item of list.slice(1)) { + if (want) { + out.push(item.text) + want = false + continue + } + if (item.type === "command_parameter") { + const flag = item.text.toLowerCase() + if (SWITCHES.has(flag)) continue + want = FLAGS.has(flag) + continue + } + out.push(item.text) + } + return out +} + +async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise { + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + for (const node of commands(root)) { + const command = parts(node) + const tokens = command.map((item) => item.text) + const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && FILES.has(cmd)) { + for (const arg of pathArgs(command, ps)) { + const resolved = await argPath(arg, cwd, ps, shell) + log.info("resolved path", { arg, resolved }) + if (!resolved || Instance.containsPath(resolved)) continue + const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + } + } + + if (tokens.length && (!cmd || !CWD.has(cmd))) { + scan.patterns.add(source(node)) + scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + } + } + + return scan +} + +function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." +} + +async function parse(command: string, ps: boolean) { + const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) + if (!tree) throw new Error("Failed to parse command") + return tree.rootNode +} + +async function ask(ctx: Tool.Context, scan: Scan) { + if (scan.dirs.size > 0) { + const globs = Array.from(scan.dirs).map((dir) => { + if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + await ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) + } + + if (scan.patterns.size === 0) return + await ctx.ask({ + permission: "bash", + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) +} + +async function shellEnv(ctx: Tool.Context, cwd: string) { + const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) + return { + ...process.env, + ...extra.env, + } +} + +function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && PS.has(name)) { + return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + windowsHide: true, + }) + } + + return spawn(command, { + shell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + windowsHide: process.platform === "win32", + }) +} + +async function run( + input: { + shell: string + name: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + description: string + }, + ctx: Tool.Context, +) { + const proc = launch(input.shell, input.name, input.command, input.cwd, input.env) + let output = "" + + ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + + const append = (chunk: Buffer) => { + output += chunk.toString() + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + } + + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) + + let expired = false + let aborted = false + let exited = false + + const kill = () => Shell.killTree(proc, { exited: () => exited }) + + if (ctx.abort.aborted) { + aborted = true + await kill() + } + + const abort = () => { + aborted = true + void kill() + } + + ctx.abort.addEventListener("abort", abort, { once: true }) + const timer = setTimeout(() => { + expired = true + void kill() + }, input.timeout + 100) + + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer) + ctx.abort.removeEventListener("abort", abort) + } + + proc.once("exit", () => { + exited = true + }) + + proc.once("close", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + const metadata: string[] = [] + if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (aborted) metadata.push("User aborted the command") + if (metadata.length > 0) { + output += "\n\n\n" + metadata.join("\n") + "\n" + } + + return { + title: input.description, + metadata: { + output: preview(output), + exit: proc.exitCode, + description: input.description, + }, + output, + } +} + const parser = lazy(async () => { const { Parser } = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { @@ -46,23 +426,36 @@ const parser = lazy(async () => { const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { with: { type: "wasm" }, }) + const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { + with: { type: "wasm" }, + }) const bashPath = resolveWasm(bashWasm) - const bashLanguage = await Language.load(bashPath) - const p = new Parser() - p.setLanguage(bashLanguage) - return p + const psPath = resolveWasm(psWasm) + const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)]) + const bash = new Parser() + bash.setLanguage(bashLanguage) + const ps = new Parser() + ps.setLanguage(psLanguage) + return { bash, ps } }) // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() + const name = Shell.name(shell) + const chain = + name === "powershell" + ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${maxLines}", String(Truncate.MAX_LINES)).replaceAll( - "${maxBytes}", - String(Truncate.MAX_BYTES), - ), + description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", name) + .replaceAll("${chaining}", chain) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -79,208 +472,29 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { - const cwd = params.workdir || Instance.directory + const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? DEFAULT_TIMEOUT - const tree = await parser().then((p) => p.parse(params.command)) - if (!tree) { - throw new Error("Failed to parse command") - } - const directories = new Set() - if (!Instance.containsPath(cwd)) directories.add(cwd) - const patterns = new Set() - const always = new Set() - const rules = new Set() // kilocode_change — hierarchy rules for permissions "npm", "npm install", "npm install lodash" - - for (const node of tree.rootNode.descendantsOfType("command")) { - if (!node) continue - - // Get full command text including redirects if present - let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text - - const command = [] - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i) - if (!child) continue - if ( - child.type !== "command_name" && - child.type !== "word" && - child.type !== "string" && - child.type !== "raw_string" && - child.type !== "concatenation" - ) { - continue - } - command.push(child.text) - } - - // not an exhaustive list, but covers most common cases - if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { - for (const arg of command.slice(1)) { - if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue - const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "") - log.info("resolved path", { arg, resolved }) - if (resolved) { - const normalized = - process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved - if (!Instance.containsPath(normalized)) { - const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized) - directories.add(dir) - } - } - } - } - - // cd covered by above check - if (command.length && command[0] !== "cd") { - patterns.add(commandText) - always.add(BashArity.prefix(command).join(" ") + " *") - BashHierarchy.addAll(rules, command, commandText) // kilocode_change - } - } - - if (directories.size > 0) { - const globs = Array.from(directories).map((dir) => { - // Preserve POSIX-looking paths with /s, even on Windows - if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*` - return path.join(dir, "*") - }) - await ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }) - } - - if (patterns.size > 0) { - await ctx.ask({ - permission: "bash", - patterns: Array.from(patterns), - always: Array.from(always), - metadata: { command: params.command, rules: Array.from(rules) }, // kilocode_change - }) - } - - const shellEnv = await Plugin.trigger( - "shell.env", - { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, - { env: {} }, - ) - const proc = spawn(params.command, { - shell, - cwd, - env: { - ...process.env, - ...shellEnv.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - windowsHide: true, // kilocode_change - prevent CMD window flash on Windows - }) - - let output = "" - - // Initialize metadata with empty output - ctx.metadata({ - metadata: { - output: "", + const ps = PS.has(name) + const root = await parse(params.command, ps) + const scan = await collect(root, cwd, ps, shell) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + await ask(ctx, scan) + + return run( + { + shell, + name, + command: params.command, + cwd, + env: await shellEnv(ctx, cwd), + timeout, description: params.description, }, - }) - - // kilocode_change start - use StringDecoder to handle multi-byte UTF-8 characters split across chunks - // separate decoder per stream so partial bytes from one pipe don't corrupt the other - const stdoutDecoder = new StringDecoder("utf8") - const stderrDecoder = new StringDecoder("utf8") - const append = (decoder: StringDecoder) => (chunk: Buffer) => { - output += decoder.write(chunk) - ctx.metadata({ - metadata: { - // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - description: params.description, - }, - }) - } - // kilocode_change end - - proc.stdout?.on("data", append(stdoutDecoder)) - proc.stderr?.on("data", append(stderrDecoder)) - - let timedOut = false - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (ctx.abort.aborted) { - aborted = true - await kill() - } - - const abortHandler = () => { - aborted = true - void kill() - } - - ctx.abort.addEventListener("abort", abortHandler, { once: true }) - - const timeoutTimer = setTimeout(() => { - timedOut = true - void kill() - }, timeout + 100) - - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeoutTimer) - ctx.abort.removeEventListener("abort", abortHandler) - } - - // kilocode_change - use "close" instead of "exit" so stdio streams are fully drained - // before we flush the StringDecoders and read `output` - proc.once("close", () => { - exited = true - cleanup() - resolve() - }) - - proc.once("error", (error) => { - exited = true - cleanup() - reject(error) - }) - }) - - // kilocode_change - flush any trailing buffered bytes from decoders - output += stdoutDecoder.end() - output += stderrDecoder.end() - - const resultMetadata: string[] = [] - - if (timedOut) { - resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) - } - - if (aborted) { - resultMetadata.push("User aborted the command") - } - - if (resultMetadata.length > 0) { - output += "\n\n\n" + resultMetadata.join("\n") + "\n" - } - - return { - title: params.description, - metadata: { - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - exit: proc.exitCode, - description: params.description, - }, - output, - } + ctx, + ) }, } }) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 3ce39606a01..8d53c90ab4e 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,6 +1,8 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. -All commands run in current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +Be aware: OS: ${os}, Shell: ${shell} + +All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -35,7 +37,7 @@ Usage notes: - Communication: Output text directly (NOT echo/printf) - When issuing multiple commands: - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel. - - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. + - ${chaining} - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - AVOID using `cd && `. Use the `workdir` parameter to change directories instead. diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 5d8885b2ad4..66eba438bc6 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,6 +1,7 @@ import path from "path" import type { Tool } from "./tool" import { Instance } from "../project/instance" +import { Filesystem } from "@/util/filesystem" type Kind = "file" | "directory" @@ -14,19 +15,23 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string if (options?.bypass) return - if (Instance.containsPath(target)) return + const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target + if (Instance.containsPath(full)) return const kind = options?.kind ?? "file" - const parentDir = kind === "directory" ? target : path.dirname(target) - const glob = path.join(parentDir, "*").replaceAll("\\", "/") + const dir = kind === "directory" ? full : path.dirname(full) + const glob = + process.platform === "win32" + ? Filesystem.normalizePathPattern(path.join(dir, "*")) + : path.join(dir, "*").replaceAll("\\", "/") await ctx.ask({ permission: "external_directory", patterns: [glob], always: [glob], metadata: { - filepath: target, - parentDir, + filepath: full, + parentDir: dir, }, }) } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 85be8f9d394..e5509fdfaee 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -33,6 +33,9 @@ export const ReadTool = Tool.define("read", { if (!path.isAbsolute(filepath)) { filepath = path.resolve(Instance.directory, filepath) } + if (process.platform === "win32") { + filepath = Filesystem.normalizePath(filepath) + } const title = path.relative(Instance.worktree, filepath) const stat = Filesystem.stat(filepath) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9c8..b4ae46df134 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve } from "path" +import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -106,13 +106,23 @@ export namespace Filesystem { */ export function normalizePath(p: string): string { if (process.platform !== "win32") return p + const resolved = win32.normalize(win32.resolve(windowsPath(p))) try { - return realpathSync.native(p) + return realpathSync.native(resolved) } catch { - return p + return resolved } } + export function normalizePathPattern(p: string): string { + if (process.platform !== "win32") return p + if (p === "*") return p + const match = p.match(/^(.*)[\\/]\*$/) + if (!match) return normalizePath(p) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") + } + // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. // Also resolves symlinks so that callers using the result as a cache key // always get the same canonical path for a given physical directory. diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9ae382abe1e..e457c163656 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2030,8 +2030,9 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { } }) + // kilocode_change start test("skips project .kilo/ directories when flag is set", async () => { - // kilocode_change + // kilocode_change end const originalEnv = process.env["KILO_DISABLE_PROJECT_CONFIG"] process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true" diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index e7d39bf87dc..24440c10eae 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { parse as parseJsonc } from "jsonc-parser" import { Filesystem } from "../../src/util/filesystem" import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug" import { tmpdir } from "../fixture/fixture" @@ -120,6 +121,99 @@ describe("plugin.install.task", () => { expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]]) }) + test("preserves JSONC comments when adding plugins to server and tui config", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server", "tui"]) + const cfg = path.join(tmp.path, ".opencode") + const server = path.join(cfg, "opencode.jsonc") + const tui = path.join(cfg, "tui.jsonc") + await fs.mkdir(cfg, { recursive: true }) + await Bun.write( + server, + `{ + // server head + "plugin": [ + // server keep + "seed@1.0.0" + ], + // server tail + "model": "x" +} +`, + ) + await Bun.write( + tui, + `{ + // tui head + "plugin": [ + // tui keep + "seed@1.0.0" + ], + // tui tail + "theme": "opencode" +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@1.2.3", + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const serverText = await fs.readFile(server, "utf8") + const tuiText = await fs.readFile(tui, "utf8") + expect(serverText).toContain("// server head") + expect(serverText).toContain("// server keep") + expect(serverText).toContain("// server tail") + expect(tuiText).toContain("// tui head") + expect(tuiText).toContain("// tui keep") + expect(tuiText).toContain("// tui tail") + + const serverJson = parseJsonc(serverText) as { plugin?: unknown[] } + const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] } + expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"]) + }) + + test("preserves JSONC comments when force replacing plugin version", async () => { + await using tmp = await tmpdir() + const target = await plugin(tmp.path, ["server"]) + const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc") + await fs.mkdir(path.dirname(cfg), { recursive: true }) + await Bun.write( + cfg, + `{ + "plugin": [ + // keep this note + "acme@1.0.0" + ] +} +`, + ) + + const run = createPlugTask( + { + mod: "acme@2.0.0", + force: true, + }, + deps(path.join(tmp.path, "global"), target), + ) + + const ok = await run(ctx(tmp.path)) + expect(ok).toBe(true) + + const text = await fs.readFile(cfg, "utf8") + expect(text).toContain("// keep this note") + + const json = parseJsonc(text) as { plugin?: unknown[] } + expect(json.plugin).toEqual(["acme@2.0.0"]) + }) + test("supports resolver target pointing to a file", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts new file mode 100644 index 00000000000..65e7e1f9019 --- /dev/null +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Pty } from "../../src/pty" +import { Shell } from "../../src/shell/shell" +import { tmpdir } from "../fixture/fixture" + +Shell.preferred.reset() + +describe("pty shell args", () => { + if (process.platform !== "win32") return + + const ps = Bun.which("pwsh") || Bun.which("powershell") + if (ps) { + test( + "does not add login args to pwsh", + async () => { + await using dir = await tmpdir() + await Instance.provide({ + directory: dir.path, + fn: async () => { + const info = await Pty.create({ command: ps, title: "pwsh" }) + try { + expect(info.args).toEqual([]) + } finally { + await Pty.remove(info.id) + } + }, + }) + }, + { timeout: 30000 }, + ) + } + + const bash = (() => { + const shell = Shell.preferred() + if (Shell.name(shell) === "bash") return shell + return Shell.gitbash() + })() + if (bash) { + test( + "adds login args to bash", + async () => { + await using dir = await tmpdir() + await Instance.provide({ + directory: dir.path, + fn: async () => { + const info = await Pty.create({ command: bash, title: "bash" }) + try { + expect(info.args).toEqual(["-l"]) + } finally { + await Pty.remove(info.id) + } + }, + }) + }, + { timeout: 30000 }, + ) + } +}) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts new file mode 100644 index 00000000000..760d6dc05aa --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Shell } from "../../src/shell/shell" +import { Filesystem } from "../../src/util/filesystem" + +const withShell = async (shell: string | undefined, fn: () => void | Promise) => { + const prev = process.env.SHELL + if (shell === undefined) delete process.env.SHELL + else process.env.SHELL = shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +describe("shell", () => { + test("normalizes shell names", () => { + expect(Shell.name("/bin/bash")).toBe("bash") + if (process.platform === "win32") { + expect(Shell.name("C:/tools/NU.EXE")).toBe("nu") + expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh") + } + }) + + test("detects login shells", () => { + expect(Shell.login("/bin/bash")).toBe(true) + expect(Shell.login("C:/tools/pwsh.exe")).toBe(false) + }) + + test("detects posix shells", () => { + expect(Shell.posix("/bin/bash")).toBe(true) + expect(Shell.posix("/bin/fish")).toBe(false) + expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false) + }) + + if (process.platform === "win32") { + test("rejects blacklisted shells case-insensitively", async () => { + await withShell("NU.EXE", async () => { + expect(Shell.name(Shell.acceptable())).not.toBe("nu") + }) + }) + + test("normalizes Git Bash shell paths from env", async () => { + const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" + await withShell(shell, async () => { + expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell)) + }) + }) + + test("resolves /usr/bin/bash from env to Git Bash", async () => { + const bash = Shell.gitbash() + if (!bash) return + await withShell("/usr/bin/bash", async () => { + expect(Shell.acceptable()).toBe(bash) + expect(Shell.preferred()).toBe(bash) + }) + }) + + test("resolves bare PowerShell shells", async () => { + const shell = Bun.which("pwsh") || Bun.which("powershell") + if (!shell) return + await withShell(path.win32.basename(shell), async () => { + expect(Shell.preferred()).toBe(shell) + }) + }) + } +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index ae2405c743d..25071c1f22f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import os from "os" import path from "path" +import { Shell } from "../../src/shell/shell" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" @@ -20,17 +21,107 @@ const ctx = { ask: async () => {}, } +Shell.acceptable.reset() +const quote = (text: string) => `"${text}"` +const squote = (text: string) => `'${text}'` const projectRoot = path.join(__dirname, "../..") +const bin = quote(process.execPath.replaceAll("\\", "/")) +const bash = (() => { + const shell = Shell.acceptable() + if (Shell.name(shell) === "bash") return shell + return Shell.gitbash() +})() +const shells = (() => { + if (process.platform !== "win32") { + const shell = Shell.acceptable() + return [{ label: Shell.name(shell), shell }] + } + + const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] + .filter((shell): shell is string => Boolean(shell)) + .map((shell) => ({ label: Shell.name(shell), shell })) + + return list.filter( + (item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, + ) +})() +const PS = new Set(["pwsh", "powershell"]) +const ps = shells.filter((item) => PS.has(item.label)) + +const sh = () => Shell.name(Shell.acceptable()) +const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) + +const fill = (mode: "lines" | "bytes", n: number) => { + const code = + mode === "lines" + ? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))" + : "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))" + const text = `${bin} -e ${evalarg(code)} ${n}` + if (PS.has(sh())) return `& ${text}` + return text +} +const glob = (p: string) => + process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + +const forms = (dir: string) => { + if (process.platform !== "win32") return [dir] + const full = Filesystem.normalizePath(dir) + const slash = full.replaceAll("\\", "/") + const root = slash.replace(/^[A-Za-z]:/, "") + return Array.from(new Set([full, slash, root, root.toLowerCase()])) +} + +const withShell = (item: { label: string; shell: string }, fn: () => Promise) => async () => { + const prev = process.env.SHELL + process.env.SHELL = item.shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +const each = (name: string, fn: (item: { label: string; shell: string }) => Promise) => { + for (const item of shells) { + test( + `${name} [${item.label}]`, + withShell(item, () => fn(item)), + ) + } +} + +const capture = (requests: Array>, stop?: Error) => ({ + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + if (stop) throw stop + }, +}) + +const mustTruncate = (result: { + metadata: { truncated?: boolean; exit?: number | null } & Record + output: string +}) => { + if (result.metadata.truncated) return + throw new Error( + [`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"), + ) +} describe("tool.bash", () => { - test("basic", async () => { + each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { const bash = await BashTool.init() const result = await bash.execute( { - command: "echo 'test'", + command: "echo test", description: "Echo test message", }, ctx, @@ -43,25 +134,19 @@ describe("tool.bash", () => { }) describe("tool.bash permissions", () => { - test("asks for bash permission with correct pattern", async () => { - await using tmp = await tmpdir({ git: true }) + each("asks for bash permission with correct pattern", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } await bash.execute( { command: "echo hello", description: "Echo hello", }, - testCtx, + capture(requests), ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") @@ -70,25 +155,19 @@ describe("tool.bash permissions", () => { }) }) - test("asks for bash permission with multiple commands", async () => { - await using tmp = await tmpdir({ git: true }) + each("asks for bash permission with multiple commands", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } await bash.execute( { command: "echo foo && echo bar", description: "Echo twice", }, - testCtx, + capture(requests), ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") @@ -98,88 +177,616 @@ describe("tool.bash permissions", () => { }) }) - test("asks for external_directory permission when cd to parent", async () => { - await using tmp = await tmpdir({ git: true }) + for (const item of ps) { + test( + `parses PowerShell conditionals for permission prompts [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + await bash.execute( + { + command: "Write-Host foo; if ($?) { Write-Host bar }", + description: "Check PowerShell conditional", + }, + capture(requests), + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("Write-Host foo") + expect(bashReq!.patterns).toContain("Write-Host bar") + expect(bashReq!.always).toContain("Write-Host *") + }, + }) + }), + ) + } + + each("asks for external_directory permission for wildcard external paths", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" + const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" + await expect( + bash.execute( + { + command: `cat ${file}`, + description: "Read wildcard path", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(want) + }, + }) + }) + + if (process.platform === "win32") { + if (bash) { + test( + "asks for nested bash command permissions [bash]", + withShell({ label: "bash", shell: bash }, async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") + const requests: Array> = [] + await bash.execute( + { + command: `echo $(cat "${file}")`, + description: "Read nested bash file", + }, + capture(requests), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`cat "${file}"`) + }, + }) + }), + ) + } + } + + if (process.platform === "win32") { + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, + description: "Copy Windows ini", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for nested PowerShell command permissions [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` + await bash.execute( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + capture(requests), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain(`Get-Content ${file}`) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: 'Get-Content "C:../outside.txt"', + description: "Read drive-relative file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$PWD/../outside.txt"', + description: "Read pwd-relative file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp.path), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*"))) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for missing PowerShell env paths [${item.label}]`, + withShell(item, async () => { + const key = "KILO_TEST_MISSING" + const prev = process.env[key] + delete process.env[key] + try { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") + await expect( + bash.execute( + { + command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, + description: "Read Windows ini with missing env", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*"))) + }, + }) + } finally { + if (prev === undefined) delete process.env[key] + else process.env[key] = prev + } + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell env paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + await bash.execute( + { + command: "Get-Content $env:WINDIR/win.ini", + description: "Read Windows ini from env", + }, + capture(requests), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + bash.execute( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]?.permission).toBe("external_directory") + if (requests[0]?.permission !== "external_directory") return + expect(requests[0].patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `treats Set-Location like cd for permissions [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + await bash.execute( + { + command: "Set-Location C:/Windows", + description: "Change location", + }, + capture(requests), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const bashReq = requests.find((r) => r.permission === "bash") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + expect(bashReq).toBeUndefined() + }, + }) + }), + ) + } + + for (const item of ps) { + test( + `does not add nested PowerShell expressions to permission prompts [${item.label}]`, + withShell(item, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + await bash.execute( + { + command: "Write-Output ('a' * 3)", + description: "Write repeated text", + }, + capture(requests), + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).not.toContain("a * 3") + expect(bashReq!.always).not.toContain("a *") + }, + }) + }), + ) + } + } + + each("asks for external_directory permission when cd to parent", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() + const err = new Error("stop after permission") const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } - await bash.execute( - { - command: "cd ../", - description: "Change to parent directory", - }, - testCtx, - ) + await expect( + bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() }, }) }) - test("asks for external_directory permission when workdir is outside project", async () => { - await using tmp = await tmpdir({ git: true }) + each("asks for external_directory permission when workdir is outside project", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() + const err = new Error("stop after permission") const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } - await bash.execute( - { - command: "ls", - workdir: os.tmpdir(), - description: "List temp dir", - }, - testCtx, - ) + await expect( + bash.execute( + { + command: "echo ok", + workdir: os.tmpdir(), + description: "Echo from temp dir", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(path.join(os.tmpdir(), "*")) + expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*"))) }, }) }) - test("asks for external_directory permission when file arg is outside project", async () => { + if (process.platform === "win32") { + test("normalizes external_directory workdir variants on Windows", async () => { + const err = new Error("stop after permission") + await using outerTmp = await tmpdir() + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) + + for (const dir of forms(outerTmp.path)) { + const requests: Array> = [] + await expect( + bash.execute( + { + command: "echo ok", + workdir: dir, + description: "Echo from external dir", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({ + dir, + patterns: [want], + always: [want], + }) + } + }, + }) + }) + + if (bash) { + test( + "uses Git Bash /tmp semantics for external workdir", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + bash.execute( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + patterns: [want], + always: [want], + }) + }, + }) + }), + ) + + test( + "uses Git Bash /tmp semantics for external file paths", + withShell({ label: "bash", shell: bash }, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const err = new Error("stop after permission") + const requests: Array> = [] + const want = glob(path.join(os.tmpdir(), "*")) + await expect( + bash.execute( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) + expect(requests[0]).toMatchObject({ + permission: "external_directory", + patterns: [want], + always: [want], + }) + }, + }) + }), + ) + } + } + + each("asks for external_directory permission when file arg is outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await using tmp = await tmpdir({ git: true }) + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() + const err = new Error("stop after permission") const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } const filepath = path.join(outerTmp.path, "outside.txt") - await bash.execute( - { - command: `cat ${filepath}`, - description: "Read external file", - }, - testCtx, - ) + await expect( + bash.execute( + { + command: `cat ${filepath}`, + description: "Read external file", + }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") - const expected = path.join(outerTmp.path, "*") + const expected = glob(path.join(outerTmp.path, "*")) expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain(expected) expect(extDirReq!.always).toContain(expected) @@ -187,82 +794,64 @@ describe("tool.bash permissions", () => { }) }) - test("does not ask for external_directory permission when rm inside project", async () => { - await using tmp = await tmpdir({ git: true }) + each("does not ask for external_directory permission when rm inside project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tmpfile"), "x") + }, + }) await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } - - await Bun.write(path.join(tmp.path, "tmpfile"), "x") - await bash.execute( { command: `rm -rf ${path.join(tmp.path, "nested")}`, - description: "remove nested dir", + description: "Remove nested dir", }, - testCtx, + capture(requests), ) - const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeUndefined() }, }) }) - test("includes always patterns for auto-approval", async () => { - await using tmp = await tmpdir({ git: true }) + each("includes always patterns for auto-approval", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } await bash.execute( { command: "git log --oneline -5", description: "Git log", }, - testCtx, + capture(requests), ) expect(requests.length).toBe(1) expect(requests[0].always.length).toBeGreaterThan(0) - expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true) + expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) }, }) }) - test("does not ask for bash permission when command is cd only", async () => { - await using tmp = await tmpdir({ git: true }) + each("does not ask for bash permission when command is cd only", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } await bash.execute( { command: "cd .", description: "Stay in current directory", }, - testCtx, + capture(requests), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeUndefined() @@ -270,41 +859,35 @@ describe("tool.bash permissions", () => { }) }) - test("matches redirects in permission pattern", async () => { - await using tmp = await tmpdir({ git: true }) + each("matches redirects in permission pattern", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() + const err = new Error("stop after permission") const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } - await bash.execute({ command: "cat > /tmp/output.txt", description: "Redirect ls output" }, testCtx) + await expect( + bash.execute( + { command: "echo test > output.txt", description: "Redirect test output" }, + capture(requests, err), + ), + ).rejects.toThrow(err.message) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() - expect(bashReq!.patterns).toContain("cat > /tmp/output.txt") + expect(bashReq!.patterns).toContain("echo test > output.txt") }, }) }) - test("always pattern has space before wildcard to not include different commands", async () => { - await using tmp = await tmpdir({ git: true }) + each("always pattern has space before wildcard to not include different commands", async () => { + await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const bash = await BashTool.init() const requests: Array> = [] - const testCtx = { - ...ctx, - ask: async (req: Omit) => { - requests.push(req) - }, - } - await bash.execute({ command: "ls -la", description: "List" }, testCtx) + await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() // kilocode_change start — hierarchy adds base wildcard + exact @@ -325,12 +908,12 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = await bash.execute( { - command: `seq 1 ${lineCount}`, + command: fill("lines", lineCount), description: "Generate lines exceeding limit", }, ctx, ) - expect((result.metadata as any).truncated).toBe(true) + mustTruncate(result) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") }, @@ -345,12 +928,12 @@ describe("tool.bash truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = await bash.execute( { - command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`, + command: fill("bytes", byteCount), description: "Generate bytes exceeding limit", }, ctx, ) - expect((result.metadata as any).truncated).toBe(true) + mustTruncate(result) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") }, @@ -369,9 +952,8 @@ describe("tool.bash truncation", () => { }, ctx, ) - expect((result.metadata as any).truncated).toBe(false) - const eol = process.platform === "win32" ? "\r\n" : "\n" - expect(result.output).toBe(`hello${eol}`) + expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) + expect(result.output).toContain("hello") }, }) }) @@ -384,18 +966,18 @@ describe("tool.bash truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = await bash.execute( { - command: `seq 1 ${lineCount}`, + command: fill("lines", lineCount), description: "Generate lines for file check", }, ctx, ) - expect((result.metadata as any).truncated).toBe(true) + mustTruncate(result) - const filepath = (result.metadata as any).outputPath + const filepath = (result.metadata as { outputPath?: string }).outputPath expect(filepath).toBeTruthy() - const saved = await Filesystem.readText(filepath) - const lines = saved.trim().split("\n") + const saved = await Filesystem.readText(filepath!) + const lines = saved.trim().split(/\r?\n/) expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") expect(lines[lineCount - 1]).toBe(String(lineCount)) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 83fbd1b9ae5..00baa9def32 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,8 @@ import path from "path" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" @@ -16,6 +18,9 @@ const baseCtx: Omit = { metadata: () => {}, } +const glob = (p: string) => + process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const requests: Array> = [] @@ -66,7 +71,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside/file.txt" - const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/") + const expected = glob(path.join(path.dirname(target), "*")) await Instance.provide({ directory, @@ -92,7 +97,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside" - const expected = path.join(target, "*").replaceAll("\\", "/") + const expected = glob(path.join(target, "*")) await Instance.provide({ directory, @@ -125,4 +130,69 @@ describe("tool.assertExternalDirectory", () => { expect(requests.length).toBe(0) }) + + if (process.platform === "win32") { + test("normalizes Windows path variants to one glob", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await using tmp = await tmpdir({ git: true }) + + const target = path.join(outerTmp.path, "outside.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, alt) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + const expected = glob(path.join(outerTmp.path, "*")) + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }) + + test("uses drive root glob for root files", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await using tmp = await tmpdir({ git: true }) + const root = path.parse(tmp.path).root + const target = path.join(root, "boot.ini") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, target) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + const expected = path.join(root, "*") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }) + } }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index afe375ae964..d4efa23bf4c 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -25,6 +25,10 @@ const ctx = { ask: async () => {}, } +const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) +const glob = (p: string) => + process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + describe("tool.read external_directory permission", () => { test("allows reading absolute path inside project directory", async () => { await using tmp = await tmpdir({ @@ -79,11 +83,44 @@ describe("tool.read external_directory permission", () => { await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path.replaceAll("\\", "/")))).toBe(true) + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*"))) }, }) }) + if (process.platform === "win32") { + test("normalizes read permission paths on Windows", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + const target = path.join(tmp.path, "test.txt") + const alt = target + .replace(/^[A-Za-z]:/, "") + .replaceAll("\\", "/") + .toLowerCase() + await read.execute({ filePath: alt }, testCtx) + const readReq = requests.find((r) => r.permission === "read") + expect(readReq).toBeDefined() + expect(readReq!.patterns).toEqual([full(target)]) + }, + }) + }) + } + test("asks for directory-scoped external_directory permission when reading external directory", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { @@ -105,7 +142,7 @@ describe("tool.read external_directory permission", () => { await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*").replaceAll("\\", "/")) + expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*"))) }, }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index aea0b1db87f..e6ace9c7227 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -555,4 +555,13 @@ describe("filesystem", () => { expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow() }) }) + + describe("normalizePathPattern()", () => { + test("preserves drive root globs on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const root = path.parse(tmp.path).root + expect(Filesystem.normalizePathPattern(path.join(root, "*"))).toBe(path.join(root, "*")) + }) + }) }) diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 1a795995e0c..57ca3ddca9a 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -24,7 +24,7 @@ function rewrite(request: Request, directory?: string) { url.searchParams.set("directory", value) } - const next = new Request(url.href, request) + const next = new Request(url.href, request) // kilocode_change next.headers.delete("x-kilo-directory") return next } diff --git a/script/changelog.ts b/script/changelog.ts index 8372d4d8916..822391206a4 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -3,7 +3,6 @@ import { $ } from "bun" import { createKilo } from "@kilocode/sdk/v2" import { parseArgs } from "util" -import { Script } from "@opencode-ai/script" type Release = { tag_name: string @@ -115,6 +114,16 @@ function filterRevertedCommits(commits: Commit[]): Commit[] { return [...seen.values()] } +const repo = process.env.GH_REPO ?? "Kilo-Org/kilocode" +const bot = ["actions-user", "opencode", "opencode-agent[bot]"] +const team = [ + ...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url)) + .text() + .then((x) => x.split(/\r?\n/).map((x) => x.trim())) + .then((x) => x.filter((x) => x && !x.startsWith("#")))), + ...bot, +] +const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const const sections = { core: "Core", tui: "TUI", @@ -127,8 +136,37 @@ const sections = { github: "Extensions", } as const -function getSection(areas: Set): string { - // Priority order for multi-area commits +function ref(input: string) { + if (input === "HEAD") return input + if (input.startsWith("v")) return input + if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}` + return input +} + +async function latest() { + const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json() + const release = (data as Release[]).find((item) => !item.draft) + if (!release) throw new Error("No releases found") + return release.tag_name.replace(/^v/, "") +} + +async function diff(base: string, head: string) { + const list: Diff[] = [] + for (let page = 1; ; page++) { + const text = + await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() + const batch = text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Diff) + if (batch.length === 0) break + list.push(...batch) + if (batch.length < 100) break + } + return list +} + +function section(areas: Set) { const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"] for (const area of priority) { if (areas.has(area)) return sections[area as keyof typeof sections] @@ -152,16 +190,25 @@ async function summarizeCommit(opencode: Awaited>, type: "text", text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:". -Commit: ${message}`, - }, - ], - }, - { - signal: AbortSignal.timeout(120_000), - }, - ) - .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message) - return result.trim() + for (const commit of commits) { + const match = commit.message.match(/^Revert "(.+)"$/) + if (match) { + const msg = match[1]! + if (seen.has(msg)) seen.delete(msg) + else seen.set(commit.message, commit) + continue + } + + const revert = `Revert "${commit.message}"` + if (seen.has(revert)) { + seen.delete(revert) + continue + } + + seen.set(commit.message, commit) + } + + return [...seen.values()] } export async function generateChangelog(commits: Commit[], opencode: Awaited>) { @@ -174,26 +221,80 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited() - for (let i = 0; i < commits.length; i++) { - const commit = commits[i]! - const section = getSection(commit.areas) - const attribution = commit.author && !Script.team.includes(commit.author) ? ` (@${commit.author})` : "" - const entry = `- ${summaries[i]}${attribution}` + const log = + await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() + + const list: Commit[] = [] + for (const hash of log.split("\n").filter(Boolean)) { + const item = data.get(hash) + if (!item) continue + if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + + const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() + const areas = new Set() + + for (const file of diff.split("\n").filter(Boolean)) { + if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") + else if (file.startsWith("packages/opencode/")) areas.add("core") + else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") + else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app") + else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk") + else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed") + else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode") + } + + if (areas.size === 0) continue - if (!grouped.has(section)) grouped.set(section, []) - grouped.get(section)!.push(entry) + list.push({ + hash: hash.slice(0, 7), + author: item.login, + message: item.message, + areas, + }) } - const sectionOrder = ["Core", "TUI", "Desktop", "SDK", "Extensions"] - const lines: string[] = [] - for (const section of sectionOrder) { - const entries = grouped.get(section) - if (!entries || entries.length === 0) continue - lines.push(`## ${section}`) - lines.push(...entries) + return reverted(list) +} + +async function contributors(from: string, to: string) { + const base = ref(from) + const head = ref(to) + + const users: User = new Map() + for (const item of await diff(base, head)) { + const title = item.message.split("\n")[0] ?? "" + if (!item.login || team.includes(item.login)) continue + if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + if (!users.has(item.login)) users.set(item.login, new Set()) + users.get(item.login)!.add(title) } + return users +} + +async function published(to: string) { + if (to === "HEAD") return + const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "") + if (!body) return + + const lines = body.split(/\r?\n/) + const start = lines.findIndex((line) => line.startsWith("**Thank you to ")) + if (start < 0) return + return lines.slice(start).join("\n").trim() +} + +async function thanks(from: string, to: string, reuse: boolean) { + const release = reuse ? await published(to) : undefined + if (release) return release.split(/\r?\n/) + + const users = await contributors(from, to) + if (users.size === 0) return [] + + const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`] + for (const [name, commits] of users) { + lines.push(`- @${name}:`) + for (const commit of commits) lines.push(` - ${commit}`) + } return lines } @@ -204,18 +305,35 @@ export async function getContributors(from: string, to: string) { await $`gh api "/repos/Kilo-Org/kilocode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text() const contributors = new Map>() - for (const line of compare.split("\n").filter(Boolean)) { - const { login, message } = JSON.parse(line) as { login: string | null; message: string } - const title = message.split("\n")[0] ?? "" - if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + for (const commit of list) { + const title = section(commit.areas) + const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" + grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) + } - if (login && !Script.team.includes(login)) { - if (!contributors.has(login)) contributors.set(login, new Set()) - contributors.get(login)!.add(title) - } + const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] + + if (list.length === 0) { + lines.push("No notable changes.") } - return contributors + for (const title of order) { + const entries = grouped.get(title) + if (!entries || entries.length === 0) continue + lines.push(`## ${title}`) + lines.push(...entries) + lines.push("") + } + + if (thanks.length > 0) { + if (lines.at(-1) !== "") lines.push("") + lines.push("## Community Contributors Input") + lines.push("") + lines.push(...thanks) + } + + if (lines.at(-1) === "") lines.pop() + return lines.join("\n") } export async function buildNotes(from: string, to: string) { @@ -283,24 +401,20 @@ if (import.meta.main) { Usage: bun script/changelog.ts [options] Options: - -f, --from Starting version (default: latest GitHub release) + -f, --from Starting version (default: latest non-draft GitHub release) -t, --to Ending ref (default: HEAD) -h, --help Show this help message Examples: - bun script/changelog.ts # Latest release to HEAD - bun script/changelog.ts --from 1.0.200 # v1.0.200 to HEAD + bun script/changelog.ts + bun script/changelog.ts --from 1.0.200 bun script/changelog.ts -f 1.0.200 -t 1.0.205 `) process.exit(0) } const to = values.to! - const from = values.from ?? (await getLatestRelease()) - - console.log(`Generating changelog: v${from} -> ${to}\n`) - - const notes = await buildNotes(from, to) - console.log("\n=== Final Notes ===") - console.log(notes.join("\n")) + const from = values.from ?? (await latest()) + const list = await commits(from, to) + console.log(format(from, to, list, await thanks(from, to, !values.from))) } diff --git a/script/version.ts b/script/version.ts index ba90cd38085..9675c5d4b39 100755 --- a/script/version.ts +++ b/script/version.ts @@ -6,7 +6,8 @@ import { $ } from "bun" const output = [`version=${Script.version}`] if (!Script.preview) { - await $`opencode run --command changelog`.cwd(process.cwd()) + const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() + await $`opencode run --command changelog -- --to ${sha}`.cwd(process.cwd()) const file = `${process.cwd()}/UPCOMING_CHANGELOG.md` const body = await Bun.file(file) .text() diff --git a/turbo.json b/turbo.json index 03f3aa6dcaf..60502f65626 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,8 @@ }, "opencode#test": { "dependsOn": ["^build"], - "outputs": [] + "outputs": [], + "passThroughEnv": ["*"] }, "@opencode-ai/app#test": { "dependsOn": ["^build"],