Merge upstream/main into feat/profiles-and-alias
This commit is contained in:
Generated
+4
-4
@@ -2331,7 +2331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "15.10.4"
|
||||
version = "15.10.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "15.10.4"
|
||||
version = "15.10.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2411,7 +2411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "15.10.4"
|
||||
version = "15.10.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2457,7 +2457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "15.10.4"
|
||||
version = "15.10.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "15.10.4"
|
||||
version = "15.10.5"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -167,7 +167,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"beautiful-mermaid": "catalog:",
|
||||
@@ -248,15 +248,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.10.4",
|
||||
"@oh-my-pi/omp-stats": "15.10.4",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.4",
|
||||
"@oh-my-pi/pi-ai": "15.10.4",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.4",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.4",
|
||||
"@oh-my-pi/pi-natives": "15.10.4",
|
||||
"@oh-my-pi/pi-tui": "15.10.4",
|
||||
"@oh-my-pi/pi-utils": "15.10.4",
|
||||
"@oh-my-pi/hashline": "15.10.5",
|
||||
"@oh-my-pi/omp-stats": "15.10.5",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.5",
|
||||
"@oh-my-pi/pi-ai": "15.10.5",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.5",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.5",
|
||||
"@oh-my-pi/pi-natives": "15.10.5",
|
||||
"@oh-my-pi/pi-tui": "15.10.5",
|
||||
"@oh-my-pi/pi-utils": "15.10.5",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -391,7 +391,7 @@
|
||||
|
||||
"@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="],
|
||||
|
||||
"@huggingface/hub": ["@huggingface/hub@2.13.0", "", { "dependencies": { "@huggingface/tasks": "^0.21.1", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-IAoqdpTV9HeMyooxKVvVGWirOJ+S2IAKnU2FSQSMz62ehPTKzxADAATy1fwAlYYQdHCV119GHFf4p9+ECQ+I5g=="],
|
||||
"@huggingface/hub": ["@huggingface/hub@2.13.1", "", { "dependencies": { "@huggingface/tasks": "^0.21.8", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-2eMXRsSvGSzayXAYKO27PoZdfREY20eNhSU4HRTFwBmKb0L2lAUO9tldJOIol1YtHahgI/cgcpJgCrZArHQtsw=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
@@ -833,7 +833,7 @@
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
|
||||
|
||||
"beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
|
||||
|
||||
@@ -937,7 +937,7 @@
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
|
||||
+107
-21
@@ -80,7 +80,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -91,7 +91,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -239,8 +239,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "brush-core"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "967a82f3b1bf090db686d5fb86424b19725073828532a42f960d5a13bff14ecb"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
@@ -269,10 +267,12 @@ dependencies = [
|
||||
"terminfo",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"uzers",
|
||||
"whoami",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -480,7 +480,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -647,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1197,7 +1197,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1603,7 +1603,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1802,7 +1802,7 @@ dependencies = [
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1812,7 +1812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1871,7 +1871,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1885,6 +1885,19 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -2237,7 +2250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9"
|
||||
dependencies = [
|
||||
"windows-core 0.51.1",
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2246,7 +2259,7 @@ version = "0.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2308,6 +2321,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -2323,13 +2345,29 @@ version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2338,42 +2376,90 @@ version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{io::Write, path::PathBuf};
|
||||
|
||||
use brush_core::{ExecutionResult, builtins, error};
|
||||
use brush_core::{ExecutionResult, builtins};
|
||||
use clap::Parser;
|
||||
|
||||
/// Change the current shell working directory.
|
||||
@@ -19,11 +19,6 @@ pub(crate) struct CdCommand {
|
||||
#[arg(short = 'e')]
|
||||
exit_on_failed_cwd_resolution: bool,
|
||||
|
||||
/// Show file with extended attributes as a dir with extended
|
||||
/// attributes.
|
||||
#[arg(short = '@')]
|
||||
file_with_xattr_as_dir: bool,
|
||||
|
||||
/// By default it is the value of the HOME shell variable. If `TARGET_DIR` is
|
||||
/// "-", it is converted to $OLDPWD.
|
||||
target_dir: Option<PathBuf>,
|
||||
@@ -36,11 +31,6 @@ impl builtins::Command for CdCommand {
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<ExecutionResult, Self::Error> {
|
||||
// TODO(cd): implement 'cd -@'
|
||||
if self.file_with_xattr_as_dir {
|
||||
return error::unimp("cd -@");
|
||||
}
|
||||
|
||||
let mut should_print = false;
|
||||
let mut target_dir = if let Some(target_dir) = &self.target_dir {
|
||||
// `cd -', equivalent to `cd $OLDPWD'
|
||||
@@ -72,11 +62,9 @@ impl builtins::Command for CdCommand {
|
||||
.options()
|
||||
.do_not_resolve_symlinks_when_changing_dir
|
||||
{
|
||||
// -e is only relevant in physical mode.
|
||||
if self.exit_on_failed_cwd_resolution {
|
||||
return error::unimp("cd -e");
|
||||
}
|
||||
|
||||
// -e is only relevant in physical mode. `canonicalize()` is the
|
||||
// cwd-resolution step this implementation supports; failures already
|
||||
// propagate as a non-zero result before updating PWD/OLDPWD.
|
||||
target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use brush_core::{ExecutionResult, builtins, error};
|
||||
use brush_core::{ExecutionResult, builtins};
|
||||
|
||||
/// No-op command.
|
||||
pub(crate) struct ColonCommand {}
|
||||
@@ -13,7 +13,10 @@ impl builtins::SimpleCommand for ColonCommand {
|
||||
builtins::ContentType::DetailedHelp => Ok("Null command; always returns success.".into()),
|
||||
builtins::ContentType::ShortUsage => Ok(":: :".into()),
|
||||
builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
|
||||
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
|
||||
builtins::ContentType::ManPage => Ok(
|
||||
"NAME\n : - Null command.\n\nSYNOPSIS\n :\n\nDESCRIPTION\n Null command.\n\n No effect; the command does nothing.\n\n Exit Status:\n Always succeeds.\n"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{collections::HashMap, fmt::Write as _, io::Write};
|
||||
use brush_core::{
|
||||
ExecutionExitCode, ExecutionResult, builtins,
|
||||
completion::{self, CompleteAction, CompleteOption, Spec},
|
||||
error, escape,
|
||||
escape,
|
||||
};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -188,6 +188,30 @@ impl CommonCompleteCommandArgs {
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
fn has_completion_spec(&self) -> bool {
|
||||
!self.options.is_empty()
|
||||
|| !self.actions.is_empty()
|
||||
|| self.glob_pattern.is_some()
|
||||
|| self.word_list.is_some()
|
||||
|| self.function_name.is_some()
|
||||
|| self.command.is_some()
|
||||
|| self.filter_pattern.is_some()
|
||||
|| self.prefix.is_some()
|
||||
|| self.suffix.is_some()
|
||||
|| self.action_alias
|
||||
|| self.action_builtin
|
||||
|| self.action_command
|
||||
|| self.action_directory
|
||||
|| self.action_exported
|
||||
|| self.action_file
|
||||
|| self.action_group
|
||||
|| self.action_job
|
||||
|| self.action_keyword
|
||||
|| self.action_service
|
||||
|| self.action_user
|
||||
|| self.action_variable
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure programmable command completion.
|
||||
@@ -234,7 +258,7 @@ impl builtins::Command for CompleteCommand {
|
||||
|| self.use_for_initial_word
|
||||
|| self.names.is_empty()
|
||||
{
|
||||
self.process_global(&mut context)?;
|
||||
result = self.process_global(&mut context)?;
|
||||
} else {
|
||||
for name in &self.names {
|
||||
if !self.try_process_for_command(&mut context, name.as_str())? {
|
||||
@@ -251,7 +275,7 @@ impl CompleteCommand {
|
||||
fn process_global(
|
||||
&self,
|
||||
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
|
||||
) -> Result<(), brush_core::Error> {
|
||||
) -> Result<ExecutionResult, brush_core::Error> {
|
||||
// Read options before taking mutable borrow on completion_config
|
||||
let extended_globbing = context.shell.options().extended_globbing;
|
||||
|
||||
@@ -272,13 +296,20 @@ impl CompleteCommand {
|
||||
};
|
||||
|
||||
// Treat 'complete' with no options the same as 'complete -p'.
|
||||
if self.print || (!self.remove && target_spec.is_none()) {
|
||||
if self.print
|
||||
|| (!self.remove && target_spec.is_none() && !self.common_args.has_completion_spec())
|
||||
{
|
||||
if let Some(target_spec) = target_spec {
|
||||
if let Some(existing_spec) = target_spec {
|
||||
let existing_spec = existing_spec.clone();
|
||||
Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
|
||||
} else {
|
||||
return error::unimp("special spec not found");
|
||||
writeln!(
|
||||
context.stderr(),
|
||||
"complete: {}: no completion specification",
|
||||
Self::special_spec_name(special_option_name)
|
||||
)?;
|
||||
return Ok(ExecutionResult::general_error());
|
||||
}
|
||||
} else {
|
||||
for (command_name, spec) in context.shell.completion_config().iter() {
|
||||
@@ -297,11 +328,22 @@ impl CompleteCommand {
|
||||
let mut new_spec = Some(self.common_args.create_spec(extended_globbing));
|
||||
std::mem::swap(&mut new_spec, target_spec);
|
||||
} else {
|
||||
return error::unimp("set unspecified spec");
|
||||
debug_assert!(self.common_args.has_completion_spec());
|
||||
writeln!(context.stderr(), "complete: invalid usage")?;
|
||||
return Ok(ExecutionExitCode::InvalidUsage.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(ExecutionResult::success())
|
||||
}
|
||||
|
||||
fn special_spec_name(option_name: &str) -> &'static str {
|
||||
match option_name {
|
||||
"-D" => "_DefaultCmD_",
|
||||
"-E" => "_EmptycmD_",
|
||||
"-I" => "_InitialWorD_",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn try_display_spec_for_command(
|
||||
@@ -510,7 +552,7 @@ impl builtins::Command for CompGenCommand {
|
||||
}
|
||||
},
|
||||
completion::Answer::RestartCompletionProcess => {
|
||||
return error::unimp("restart completion");
|
||||
return Ok(ExecutionResult::general_error());
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::{io::Write, sync::LazyLock};
|
||||
use brush_core::{
|
||||
ErrorKind, ExecutionResult, builtins,
|
||||
env::{self, EnvironmentLookup, EnvironmentScope},
|
||||
error,
|
||||
parser::ast,
|
||||
variables::{
|
||||
self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
|
||||
@@ -132,10 +131,6 @@ impl builtins::Command for DeclareCommand {
|
||||
return Ok(ExecutionResult::general_error());
|
||||
}
|
||||
|
||||
if self.locals_inherit_from_prev_scope {
|
||||
return error::unimp("declare -I");
|
||||
}
|
||||
|
||||
let mut result = ExecutionResult::success();
|
||||
if !self.declarations.is_empty() {
|
||||
for declaration in &self.declarations {
|
||||
@@ -303,7 +298,28 @@ impl DeclareCommand {
|
||||
ShellValueUnsetType::Untyped
|
||||
};
|
||||
|
||||
let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
|
||||
let mut var = if create_var_local
|
||||
&& (self.locals_inherit_from_prev_scope
|
||||
|| context.shell.options().local_vars_inherit_value_and_attrs)
|
||||
{
|
||||
if let Some(prev_var) = context
|
||||
.shell
|
||||
.env()
|
||||
.get_using_policy(name.as_str(), EnvironmentLookup::Anywhere)
|
||||
{
|
||||
if prev_var.is_readonly() {
|
||||
return Err(ErrorKind::ReadonlyVariable.into());
|
||||
}
|
||||
|
||||
let mut var = prev_var.clone();
|
||||
var.unset_treat_as_nameref();
|
||||
var
|
||||
} else {
|
||||
ShellVariable::new(ShellValue::Unset(unset_type))
|
||||
}
|
||||
} else {
|
||||
ShellVariable::new(ShellValue::Unset(unset_type))
|
||||
};
|
||||
|
||||
self.apply_attributes_before_update(&mut var)?;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::io::Write;
|
||||
|
||||
use brush_core::{ExecutionResult, builtins, error};
|
||||
use brush_core::{ExecutionResult, builtins};
|
||||
use clap::Parser;
|
||||
use itertools::Itertools;
|
||||
|
||||
@@ -44,15 +44,26 @@ impl builtins::Command for EnableCommand {
|
||||
) -> Result<ExecutionResult, Self::Error> {
|
||||
let mut result = ExecutionResult::success();
|
||||
|
||||
if self.shared_object_path.is_some() {
|
||||
return error::unimp("enable -f");
|
||||
}
|
||||
if self.remove_loaded_builtin {
|
||||
return error::unimp("enable -d");
|
||||
if let Some(shared_object_path) = &self.shared_object_path {
|
||||
writeln!(
|
||||
context.stderr(),
|
||||
"{}: cannot open shared object {shared_object_path}: dynamic loading is not supported",
|
||||
context.command_name
|
||||
)?;
|
||||
return Ok(ExecutionResult::general_error());
|
||||
}
|
||||
|
||||
if !self.names.is_empty() {
|
||||
for name in &self.names {
|
||||
if self.remove_loaded_builtin {
|
||||
if context.shell.builtins().contains_key(name) {
|
||||
writeln!(context.stderr(), "{name}: not dynamically loaded")?;
|
||||
} else {
|
||||
writeln!(context.stderr(), "{name}: not a shell builtin")?;
|
||||
}
|
||||
result = ExecutionResult::general_error();
|
||||
continue;
|
||||
}
|
||||
if let Some(builtin) = context.shell.builtin_mut(name) {
|
||||
builtin.disabled = self.disable;
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{borrow::Cow, os::unix::process::CommandExt};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
os::unix::process::{CommandExt, ExitStatusExt},
|
||||
};
|
||||
|
||||
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
|
||||
use clap::Parser;
|
||||
@@ -47,7 +50,7 @@ impl builtins::Command for ExecCommand {
|
||||
// expectation of returning.
|
||||
if context.shell.is_subshell() {
|
||||
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
|
||||
return brush_core::error::unimp("exec with options in subshell not yet supported");
|
||||
return self.execute_external_in_subshell(context).await;
|
||||
}
|
||||
|
||||
let cmd_cmd = crate::command::CommandCommand {
|
||||
@@ -58,16 +61,12 @@ impl builtins::Command for ExecCommand {
|
||||
return cmd_cmd.execute(context).await;
|
||||
}
|
||||
|
||||
let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
|
||||
|
||||
if self.exec_as_login {
|
||||
argv0 = Cow::Owned(std::format!("-{argv0}"));
|
||||
}
|
||||
let argv0 = self.argv0();
|
||||
|
||||
let mut cmd = commands::compose_std_command(
|
||||
&context,
|
||||
&self.args[0],
|
||||
argv0.as_str(),
|
||||
argv0.as_ref(),
|
||||
&self.args[1..],
|
||||
self.empty_environment,
|
||||
)?;
|
||||
@@ -81,3 +80,61 @@ impl builtins::Command for ExecCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCommand {
|
||||
fn argv0(&self) -> Cow<'_, str> {
|
||||
let argv0 = self
|
||||
.name_for_argv0
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| self.args[0].as_str());
|
||||
|
||||
if self.exec_as_login {
|
||||
Cow::Owned(std::format!("-{argv0}"))
|
||||
} else {
|
||||
Cow::Borrowed(argv0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_external_in_subshell<SE: brush_core::ShellExtensions>(
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<ExecutionResult, brush_core::Error> {
|
||||
let argv0 = self.argv0();
|
||||
let cmd = commands::compose_std_command(
|
||||
&context,
|
||||
&self.args[0],
|
||||
argv0.as_ref(),
|
||||
&self.args[1..],
|
||||
self.empty_environment,
|
||||
)?;
|
||||
|
||||
let mut cmd = tokio::process::Command::from(cmd);
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(spawn_err) => {
|
||||
if spawn_err.kind() == std::io::ErrorKind::NotFound {
|
||||
return Ok(ExecutionExitCode::NotFound.into());
|
||||
}
|
||||
|
||||
return Err(ErrorKind::from(spawn_err).into());
|
||||
},
|
||||
};
|
||||
|
||||
let status = child.wait().await?;
|
||||
|
||||
if let Some(code) = status.code() {
|
||||
#[expect(clippy::cast_sign_loss)]
|
||||
return Ok(ExecutionResult::new((code & 0xff) as u8));
|
||||
}
|
||||
|
||||
if let Some(signal) = status.signal() {
|
||||
#[expect(clippy::cast_sign_loss)]
|
||||
return Ok(ExecutionResult::new((signal & 0xff) as u8 + 128));
|
||||
}
|
||||
|
||||
tracing::error!("unhandled process exit");
|
||||
Ok(ExecutionExitCode::NotFound.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use brush_core::{ExecutionResult, builtins, error};
|
||||
use brush_core::{ExecutionResult, builtins};
|
||||
|
||||
/// Return exit code 1.
|
||||
pub(crate) struct FalseCommand {}
|
||||
@@ -13,7 +13,10 @@ impl builtins::SimpleCommand for FalseCommand {
|
||||
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
|
||||
builtins::ContentType::ShortUsage => Ok("false".into()),
|
||||
builtins::ContentType::ShortDescription => Ok("false - fail".into()),
|
||||
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
|
||||
builtins::ContentType::ManPage => Ok(
|
||||
"NAME\n false - Return an unsuccessful result.\n\nSYNOPSIS\n false\n\nDESCRIPTION\n Return an unsuccessful result.\n\n Exit Status:\n Always fails.\n\nSEE ALSO\n bash(1)\n"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use std::io::Write;
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use brush_core::{ExecutionResult, builtins, error, history};
|
||||
use clap::Parser;
|
||||
@@ -50,7 +54,7 @@ impl builtins::Command for FcCommand {
|
||||
return self.do_list(&context);
|
||||
}
|
||||
|
||||
error::unimp("fc editor mode is not yet implemented")
|
||||
self.do_edit(context).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +92,96 @@ impl FcCommand {
|
||||
Ok(ExecutionResult::success())
|
||||
}
|
||||
|
||||
async fn do_edit(
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
|
||||
) -> Result<ExecutionResult, brush_core::Error> {
|
||||
let history = context
|
||||
.shell
|
||||
.history()
|
||||
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
|
||||
|
||||
let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
|
||||
let mut commands = String::new();
|
||||
let indices: Vec<usize> = if reverse {
|
||||
(first_idx..=last_idx).rev().collect()
|
||||
} else {
|
||||
(first_idx..=last_idx).collect()
|
||||
};
|
||||
|
||||
for idx in indices {
|
||||
let item = history
|
||||
.get(idx)
|
||||
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?;
|
||||
commands.push_str(&item.command_line);
|
||||
commands.push('\n');
|
||||
}
|
||||
|
||||
let editor = self.editor_name(&context);
|
||||
if editor.as_deref() != Some("-") {
|
||||
let temp_file = FcTempFile::create()?;
|
||||
fs::write(temp_file.path(), commands)?;
|
||||
|
||||
let edit_cmd = format!(
|
||||
"{} {}",
|
||||
editor.as_deref().unwrap_or("vi"),
|
||||
shell_quote_path(temp_file.path())
|
||||
);
|
||||
let source_info = brush_core::SourceInfo::from("(fc editor)");
|
||||
let edit_result = context
|
||||
.shell
|
||||
.run_string(edit_cmd, &source_info, &context.params)
|
||||
.await?;
|
||||
if !edit_result.is_success() {
|
||||
return Ok(edit_result);
|
||||
}
|
||||
|
||||
commands = fs::read_to_string(temp_file.path())?;
|
||||
}
|
||||
|
||||
let history_mut = context
|
||||
.shell
|
||||
.history_mut()
|
||||
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
|
||||
history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
|
||||
|
||||
if commands.trim().is_empty() {
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
let source_info = brush_core::SourceInfo::from("(history)");
|
||||
let result = context
|
||||
.shell
|
||||
.run_string(commands.clone(), &source_info, &context.params)
|
||||
.await?;
|
||||
context.shell.add_to_history(commands.trim_end())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn editor_name(
|
||||
&self,
|
||||
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
|
||||
) -> Option<String> {
|
||||
if let Some(editor) = self.editor.as_ref().filter(|value| !value.is_empty()) {
|
||||
return Some(editor.clone());
|
||||
}
|
||||
|
||||
context
|
||||
.shell
|
||||
.env()
|
||||
.get_str("FCEDIT", context.shell)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
context
|
||||
.shell
|
||||
.env()
|
||||
.get_str("EDITOR", context.shell)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.map(|value| value.into_owned())
|
||||
}
|
||||
|
||||
async fn do_execute(
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
|
||||
@@ -291,6 +385,55 @@ impl FcCommand {
|
||||
}
|
||||
}
|
||||
|
||||
struct FcTempFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FcTempFile {
|
||||
fn create() -> Result<Self, brush_core::Error> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let process_id = std::process::id();
|
||||
|
||||
for attempt in 0_u32..100 {
|
||||
let path = temp_dir.join(format!("brush-fc-{process_id}-{attempt}.sh"));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(_) => return Ok(Self { path }),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {},
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"failed to create a unique fc temporary file",
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FcTempFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote_path(path: &Path) -> String {
|
||||
let mut quoted = String::from("'");
|
||||
for ch in path.to_string_lossy().chars() {
|
||||
if ch == '\'' {
|
||||
quoted.push_str("'\\''");
|
||||
} else {
|
||||
quoted.push(ch);
|
||||
}
|
||||
}
|
||||
quoted.push('\'');
|
||||
quoted
|
||||
}
|
||||
|
||||
/// Returns the effective history count (excluding the fc command itself).
|
||||
fn effective_history_count(history: &history::History) -> usize {
|
||||
history.count().saturating_sub(1)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{io::Write, path::PathBuf};
|
||||
use std::{fs::File, io::Write, path::PathBuf};
|
||||
|
||||
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
|
||||
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, history};
|
||||
use clap::Parser;
|
||||
|
||||
/// Query or manipulate the shell's command history.
|
||||
@@ -135,12 +135,24 @@ impl HistoryCommand {
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
if self.append_rest_of_file_to_session.is_some() {
|
||||
return error::unimp("history -n is not yet implemented");
|
||||
if let Some(read_option) = &self.append_rest_of_file_to_session {
|
||||
if let Some(file_path) =
|
||||
get_effective_history_file_path(config.default_history_file_path, read_option.as_ref())
|
||||
{
|
||||
append_history_file_to_session(history, file_path, HistoryReadMode::Unread)?;
|
||||
}
|
||||
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
if self.append_file_to_session.is_some() {
|
||||
return error::unimp("history -r is not yet implemented");
|
||||
if let Some(read_option) = &self.append_file_to_session {
|
||||
if let Some(file_path) =
|
||||
get_effective_history_file_path(config.default_history_file_path, read_option.as_ref())
|
||||
{
|
||||
append_history_file_to_session(history, file_path, HistoryReadMode::All)?;
|
||||
}
|
||||
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
if let Some(write_option) = &self.write_session_to_file {
|
||||
@@ -158,8 +170,8 @@ impl HistoryCommand {
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
if self.expand_args.is_some() {
|
||||
return error::unimp("history -p is not yet implemented");
|
||||
if let Some(args) = &self.expand_args {
|
||||
return expand_history_args(history, args, stdout, stderr);
|
||||
}
|
||||
|
||||
if let Some(args) = &self.append_args_to_session {
|
||||
@@ -179,6 +191,219 @@ impl HistoryCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_history_args(
|
||||
history: &history::History,
|
||||
args: &[String],
|
||||
mut stdout: impl Write,
|
||||
mut stderr: impl Write,
|
||||
) -> Result<ExecutionResult, brush_core::Error> {
|
||||
let mut result = ExecutionResult::success();
|
||||
|
||||
for arg in args {
|
||||
match expand_history_arg(history, arg) {
|
||||
Ok(expanded) => {
|
||||
writeln!(stdout, "{expanded}")?;
|
||||
},
|
||||
Err(()) => {
|
||||
writeln!(stderr, "history: {arg}: history expansion failed")?;
|
||||
result = ExecutionResult::general_error();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn expand_history_arg(history: &history::History, arg: &str) -> Result<String, ()> {
|
||||
let chars: Vec<char> = arg.chars().collect();
|
||||
let mut expanded = String::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
if chars[i] != '!' {
|
||||
expanded.push(chars[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
if i == chars.len() {
|
||||
expanded.push('!');
|
||||
break;
|
||||
}
|
||||
|
||||
let event = match chars[i] {
|
||||
'!' => {
|
||||
i += 1;
|
||||
latest_history_event(history)?
|
||||
},
|
||||
'#' => {
|
||||
i += 1;
|
||||
let current_line = expanded.clone();
|
||||
expanded.push_str(¤t_line);
|
||||
continue;
|
||||
},
|
||||
':' => latest_history_event(history)?,
|
||||
'$' | '^' | '*' => {
|
||||
let event = latest_history_event(history)?;
|
||||
let selected = select_history_words(&event, chars[i], None)?;
|
||||
i += 1;
|
||||
expanded.push_str(&selected);
|
||||
continue;
|
||||
},
|
||||
'-' => {
|
||||
i += 1;
|
||||
let (offset, next_i) = parse_history_number(&chars, i).ok_or(())?;
|
||||
i = next_i;
|
||||
relative_history_event(history, offset)?
|
||||
},
|
||||
'?' => {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < chars.len() && chars[i] != '?' {
|
||||
i += 1;
|
||||
}
|
||||
let needle: String = chars[start..i].iter().collect();
|
||||
if i < chars.len() && chars[i] == '?' {
|
||||
i += 1;
|
||||
}
|
||||
find_history_event(history, &needle, HistorySearchMode::Contains)?
|
||||
},
|
||||
c if c.is_ascii_digit() => {
|
||||
let (number, next_i) = parse_history_number(&chars, i).ok_or(())?;
|
||||
i = next_i;
|
||||
numbered_history_event(history, number)?
|
||||
},
|
||||
c if is_history_event_char(c) => {
|
||||
let start = i;
|
||||
while i < chars.len() && is_history_event_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let prefix: String = chars[start..i].iter().collect();
|
||||
find_history_event(history, &prefix, HistorySearchMode::Prefix)?
|
||||
},
|
||||
_ => {
|
||||
expanded.push('!');
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
if i < chars.len() && chars[i] == ':' {
|
||||
i += 1;
|
||||
if i == chars.len() {
|
||||
return Err(());
|
||||
}
|
||||
let selector = chars[i];
|
||||
i += 1;
|
||||
let number = if selector.is_ascii_digit() {
|
||||
let (number, next_i) = parse_history_number_from_first(&chars, i - 1);
|
||||
i = next_i;
|
||||
Some(number)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let selected = select_history_words(&event, selector, number)?;
|
||||
expanded.push_str(&selected);
|
||||
} else {
|
||||
expanded.push_str(&event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
fn latest_history_event(history: &history::History) -> Result<String, ()> {
|
||||
history
|
||||
.iter()
|
||||
.last()
|
||||
.map(|item| item.command_line.clone())
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn numbered_history_event(history: &history::History, number: usize) -> Result<String, ()> {
|
||||
if number == 0 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
history
|
||||
.get(number - 1)
|
||||
.map(|item| item.command_line.clone())
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn relative_history_event(history: &history::History, offset: usize) -> Result<String, ()> {
|
||||
let count = history.count();
|
||||
if offset == 0 || offset > count {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
numbered_history_event(history, count - offset + 1)
|
||||
}
|
||||
|
||||
enum HistorySearchMode {
|
||||
Prefix,
|
||||
Contains,
|
||||
}
|
||||
|
||||
fn find_history_event(
|
||||
history: &history::History,
|
||||
needle: &str,
|
||||
mode: HistorySearchMode,
|
||||
) -> Result<String, ()> {
|
||||
let mut match_result = None;
|
||||
for item in history.iter() {
|
||||
let matches = match mode {
|
||||
HistorySearchMode::Prefix => item.command_line.starts_with(needle),
|
||||
HistorySearchMode::Contains => item.command_line.contains(needle),
|
||||
};
|
||||
if matches {
|
||||
match_result = Some(item.command_line.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match_result.ok_or(())
|
||||
}
|
||||
|
||||
fn select_history_words(
|
||||
event: &str,
|
||||
selector: char,
|
||||
number: Option<usize>,
|
||||
) -> Result<String, ()> {
|
||||
let words: Vec<&str> = event.split_whitespace().collect();
|
||||
match selector {
|
||||
'0'..='9' => {
|
||||
let index = number.ok_or(())?;
|
||||
words.get(index).map(|word| (*word).to_owned()).ok_or(())
|
||||
},
|
||||
'^' => words.get(1).map(|word| (*word).to_owned()).ok_or(()),
|
||||
'$' => words.last().map(|word| (*word).to_owned()).ok_or(()),
|
||||
'*' => Ok(words.get(1..).unwrap_or_default().join(" ")),
|
||||
'p' => Ok(event.to_owned()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_history_number(chars: &[char], i: usize) -> Option<(usize, usize)> {
|
||||
if i == chars.len() || !chars[i].is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(parse_history_number_from_first(chars, i))
|
||||
}
|
||||
|
||||
fn parse_history_number_from_first(chars: &[char], mut i: usize) -> (usize, usize) {
|
||||
let mut value = 0;
|
||||
while i < chars.len() && chars[i].is_ascii_digit() {
|
||||
value = value * 10 + chars[i].to_digit(10).unwrap_or_default() as usize;
|
||||
i += 1;
|
||||
}
|
||||
(value, i)
|
||||
}
|
||||
|
||||
fn is_history_event_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/')
|
||||
}
|
||||
|
||||
fn display_history(
|
||||
history: &history::History,
|
||||
config: &HistoryConfig,
|
||||
@@ -213,6 +438,30 @@ fn display_history(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum HistoryReadMode {
|
||||
All,
|
||||
Unread,
|
||||
}
|
||||
|
||||
fn append_history_file_to_session(
|
||||
history: &mut history::History,
|
||||
file_path: PathBuf,
|
||||
mode: HistoryReadMode,
|
||||
) -> Result<(), brush_core::Error> {
|
||||
let file = File::open(file_path)?;
|
||||
let imported_history = history::History::import(file)?;
|
||||
let already_read_count = match mode {
|
||||
HistoryReadMode::All => 0,
|
||||
HistoryReadMode::Unread => history.iter().filter(|item| !item.dirty).count(),
|
||||
};
|
||||
|
||||
for item in imported_history.iter().skip(already_read_count) {
|
||||
history.add(item.clone())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_effective_history_file_path(
|
||||
default_history_file_path: Option<PathBuf>,
|
||||
option: Option<&String>,
|
||||
@@ -222,6 +471,12 @@ fn get_effective_history_file_path(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::{assert_eq, assert_matches};
|
||||
|
||||
@@ -240,4 +495,49 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_history_file_to_session_reads_all_entries() -> Result<()> {
|
||||
let file_path = write_temp_history("history-r", "one\ntwo\n")?;
|
||||
let mut history = history::History::default();
|
||||
history.add(history::Item::new("local"))?;
|
||||
|
||||
append_history_file_to_session(&mut history, file_path.clone(), HistoryReadMode::All)?;
|
||||
|
||||
assert_eq!(history.count(), 3);
|
||||
assert_eq!(history.get(0).map(|item| item.command_line.as_str()), Some("local"));
|
||||
assert_eq!(history.get(1).map(|item| item.command_line.as_str()), Some("one"));
|
||||
assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("two"));
|
||||
assert_eq!(history.get(1).map(|item| item.dirty), Some(false));
|
||||
fs::remove_file(file_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_history_file_to_session_reads_unread_entries_after_clean_history() -> Result<()> {
|
||||
let initial_file_path = write_temp_history("history-n-initial", "one\ntwo\n")?;
|
||||
let mut history = history::History::import(fs::File::open(&initial_file_path)?)?;
|
||||
history.add(history::Item::new("local"))?;
|
||||
|
||||
let updated_file_path = write_temp_history("history-n-updated", "one\ntwo\nthree\n")?;
|
||||
append_history_file_to_session(&mut history, updated_file_path.clone(), HistoryReadMode::Unread)?;
|
||||
|
||||
assert_eq!(history.count(), 4);
|
||||
assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("local"));
|
||||
assert_eq!(history.get(3).map(|item| item.command_line.as_str()), Some("three"));
|
||||
assert_eq!(history.get(3).map(|item| item.dirty), Some(false));
|
||||
fs::remove_file(initial_file_path)?;
|
||||
fs::remove_file(updated_file_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_temp_history(name: &str, contents: &str) -> Result<PathBuf> {
|
||||
let mut path = std::env::temp_dir();
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
path.push(format!("brush-{name}-{nanos}.history"));
|
||||
fs::write(&path, contents)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::io::Write;
|
||||
|
||||
use brush_core::{ExecutionResult, builtins, error, jobs};
|
||||
use brush_core::{ExecutionResult, builtins, jobs};
|
||||
use clap::Parser;
|
||||
|
||||
/// Manage jobs.
|
||||
@@ -38,22 +38,31 @@ impl builtins::Command for JobsCommand {
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<brush_core::ExecutionResult, Self::Error> {
|
||||
if self.also_show_pids {
|
||||
return error::unimp("jobs -l");
|
||||
}
|
||||
if self.list_changed_only {
|
||||
return error::unimp("jobs -n");
|
||||
for (job, result) in context.shell.jobs_mut().poll()? {
|
||||
result?;
|
||||
self.display_job(&context, &job)?;
|
||||
}
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
let mut exit_code = ExecutionResult::success();
|
||||
if self.job_specs.is_empty() {
|
||||
for job in &context.shell.jobs().jobs {
|
||||
self.display_job(&context, job)?;
|
||||
}
|
||||
} else {
|
||||
return error::unimp("jobs with job specs");
|
||||
for job_spec in &self.job_specs {
|
||||
if let Some(job) = resolve_job_spec(context.shell.jobs(), job_spec) {
|
||||
self.display_job(&context, job)?;
|
||||
} else {
|
||||
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, job_spec)?;
|
||||
exit_code = ExecutionResult::general_error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExecutionResult::success())
|
||||
Ok(exit_code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +83,14 @@ impl JobsCommand {
|
||||
if let Some(pid) = job.representative_pid() {
|
||||
writeln!(context.stdout(), "{pid}")?;
|
||||
}
|
||||
} else if self.also_show_pids {
|
||||
write!(context.stdout(), "[{}]{:3}", job.id, job.annotation())?;
|
||||
if let Some(pid) = job.representative_pid() {
|
||||
write!(context.stdout(), "{pid}\t")?;
|
||||
} else {
|
||||
write!(context.stdout(), "<pid unknown>\t")?;
|
||||
}
|
||||
writeln!(context.stdout(), "{}\t{}", job.state, job.command_line)?;
|
||||
} else {
|
||||
writeln!(context.stdout(), "{job}")?;
|
||||
}
|
||||
@@ -81,3 +98,13 @@ impl JobsCommand {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_job_spec<'a>(job_manager: &'a jobs::JobManager, job_spec: &str) -> Option<&'a jobs::Job> {
|
||||
match job_manager.resolve_job_spec_selector(job_spec)? {
|
||||
jobs::JobSelector::JobId(id) => job_manager.jobs.iter().find(|job| job.id == id),
|
||||
jobs::JobSelector::ProcessId(pid) => job_manager
|
||||
.jobs
|
||||
.iter()
|
||||
.find(|job| job.representative_pid().is_some_and(|job_pid| job_pid == pid)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
|
||||
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, escape, variables};
|
||||
use clap::Parser;
|
||||
|
||||
/// Read lines from standard input into an indexed array variable.
|
||||
@@ -48,11 +48,8 @@ impl builtins::Command for MapFileCommand {
|
||||
|
||||
async fn execute<SE: brush_core::ShellExtensions>(
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
mut context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<brush_core::ExecutionResult, Self::Error> {
|
||||
if self.callback_group_size != 5000 || self.callback.is_some() {
|
||||
return error::unimp("mapfile -C/-c is not yet implemented");
|
||||
}
|
||||
|
||||
if let Some(origin) = self.origin {
|
||||
if origin < 0 {
|
||||
@@ -81,49 +78,39 @@ impl builtins::Command for MapFileCommand {
|
||||
.try_fd(self.fd)
|
||||
.ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?;
|
||||
|
||||
// Read!
|
||||
let results = self.read_entries(input_file)?;
|
||||
|
||||
if let Some(origin) = self.origin {
|
||||
// -O: preserve existing array, assign at offset.
|
||||
for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() {
|
||||
// If the user is getting to wraparounds in *bash*, they got bigger problems.
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
let elem_idx = elem_idx as i64;
|
||||
context.shell.env_mut().update_or_add_array_element(
|
||||
&self.array_var_name,
|
||||
(elem_idx + origin).to_string(),
|
||||
value,
|
||||
|_| Ok(()),
|
||||
env::EnvironmentLookup::Anywhere,
|
||||
env::EnvironmentScope::Global,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// No -O: replace the entire variable (clears existing).
|
||||
// Read and assign entries. When no origin is specified, bash clears the
|
||||
// target array before reading; callbacks then see earlier assigned entries
|
||||
// but not the entry that is currently being delivered to the callback.
|
||||
if self.origin.is_none() {
|
||||
context.shell.env_mut().update_or_add(
|
||||
&self.array_var_name,
|
||||
variables::ShellValueLiteral::Array(results),
|
||||
variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![])),
|
||||
|_| Ok(()),
|
||||
env::EnvironmentLookup::Anywhere,
|
||||
env::EnvironmentScope::Global,
|
||||
)?;
|
||||
}
|
||||
|
||||
if let Some(result) = self.read_entries(input_file, &mut context).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
Ok(ExecutionResult::success())
|
||||
}
|
||||
}
|
||||
|
||||
impl MapFileCommand {
|
||||
fn read_entries(
|
||||
async fn read_entries<SE: brush_core::ShellExtensions>(
|
||||
&self,
|
||||
mut input_file: brush_core::openfiles::OpenFile,
|
||||
) -> Result<variables::ArrayLiteral, brush_core::Error> {
|
||||
context: &mut brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<Option<ExecutionResult>, brush_core::Error> {
|
||||
let _term_mode = setup_terminal_settings(&input_file)?;
|
||||
|
||||
let mut entries = vec![];
|
||||
let mut entry_count = 0usize;
|
||||
let mut read_count = 0;
|
||||
let max_count = self.max_count.try_into()?;
|
||||
let callback_group_size: usize = self.callback_group_size.try_into()?;
|
||||
let delimiter = match &self.delimiter {
|
||||
Some(d) if d.is_empty() => b'\0',
|
||||
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
|
||||
@@ -132,7 +119,7 @@ impl MapFileCommand {
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
|
||||
while max_count == 0 || entries.len() < max_count {
|
||||
while max_count == 0 || entry_count < max_count {
|
||||
let mut line = vec![];
|
||||
let mut saw_delimiter = false;
|
||||
|
||||
@@ -168,14 +155,54 @@ impl MapFileCommand {
|
||||
}
|
||||
|
||||
let line_str = String::from_utf8_lossy(&line).to_string();
|
||||
let array_index = self.origin.unwrap_or(0) + i64::try_from(entry_count)?;
|
||||
|
||||
entries.push((None, line_str));
|
||||
if let Some(callback) = &self.callback
|
||||
&& (entry_count + 1) % callback_group_size == 0
|
||||
{
|
||||
let result = run_callback(callback, array_index, &line_str, context).await?;
|
||||
if !result.is_normal_flow() {
|
||||
return Ok(Some(result));
|
||||
}
|
||||
}
|
||||
|
||||
context.shell.env_mut().update_or_add_array_element(
|
||||
&self.array_var_name,
|
||||
array_index.to_string(),
|
||||
line_str,
|
||||
|_| Ok(()),
|
||||
env::EnvironmentLookup::Anywhere,
|
||||
env::EnvironmentScope::Global,
|
||||
)?;
|
||||
|
||||
entry_count += 1;
|
||||
}
|
||||
|
||||
Ok(variables::ArrayLiteral(entries))
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_callback<SE: brush_core::ShellExtensions>(
|
||||
callback: &str,
|
||||
array_index: i64,
|
||||
line: &str,
|
||||
context: &mut brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<ExecutionResult, brush_core::Error> {
|
||||
let index_arg = array_index.to_string();
|
||||
let index_arg = escape::quote_if_needed(&index_arg, escape::QuoteMode::SingleQuote);
|
||||
let line_arg = escape::quote_if_needed(line, escape::QuoteMode::SingleQuote);
|
||||
|
||||
let mut command = String::with_capacity(callback.len() + index_arg.len() + line_arg.len() + 2);
|
||||
command.push_str(callback);
|
||||
command.push(' ');
|
||||
command.push_str(index_arg.as_ref());
|
||||
command.push(' ');
|
||||
command.push_str(line_arg.as_ref());
|
||||
|
||||
let source_info = context.shell.call_stack().current_pos_as_source_info();
|
||||
context.shell.run_string(command, &source_info, &context.params).await
|
||||
}
|
||||
|
||||
fn setup_terminal_settings(
|
||||
file: &brush_core::openfiles::OpenFile,
|
||||
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
|
||||
|
||||
@@ -84,12 +84,6 @@ impl builtins::Command for ReadCommand {
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<brush_core::ExecutionResult, Self::Error> {
|
||||
if self.use_readline {
|
||||
return error::unimp("read -e");
|
||||
}
|
||||
if self.initial_text.is_some() {
|
||||
return error::unimp("read -i");
|
||||
}
|
||||
|
||||
// Validate timeout value if provided.
|
||||
if let Some(result) = self.validate_timeout(&context)? {
|
||||
@@ -105,6 +99,17 @@ impl builtins::Command for ReadCommand {
|
||||
let input_stream = context
|
||||
.try_fd(fd_num)
|
||||
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?;
|
||||
// Bash only uses readline for `read -e` when reading from a terminal.
|
||||
// For non-terminal input, `-e` and `-i` are accepted but do not affect
|
||||
// the bytes read. `-i` only supplies initial text to readline, so without
|
||||
// an available readline-backed terminal path it is likewise a no-op.
|
||||
if self.use_readline && input_stream.is_terminal() {
|
||||
return error::unimp(if self.initial_text.is_some() {
|
||||
"read -e -i"
|
||||
} else {
|
||||
"read -e"
|
||||
});
|
||||
}
|
||||
|
||||
// Retrieve effective value of IFS for splitting.
|
||||
// We convert to owned String to release the borrow before the mutable borrow
|
||||
@@ -272,6 +277,7 @@ fn build_variable_fields(
|
||||
///
|
||||
/// This enum clearly represents all possible outcomes of `read_line()`,
|
||||
/// making the contract with callers explicit.
|
||||
#[derive(Debug)]
|
||||
enum ReadResult {
|
||||
/// Successfully read a complete line (delimiter or char limit reached).
|
||||
Line(String),
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
use brush_core::{ExecutionResult, builtins, error};
|
||||
use brush_core::{ExecutionResult, builtins};
|
||||
|
||||
/// No-op command. Same with :.
|
||||
pub(crate) struct TrueCommand {}
|
||||
|
||||
const MAN_PAGE: &str = "\
|
||||
TRUE(1)
|
||||
|
||||
NAME
|
||||
true - return a successful result
|
||||
|
||||
SYNOPSIS
|
||||
true
|
||||
|
||||
DESCRIPTION
|
||||
The true utility returns a successful exit status.
|
||||
";
|
||||
|
||||
impl builtins::SimpleCommand for TrueCommand {
|
||||
fn get_content(
|
||||
_name: &str,
|
||||
@@ -13,7 +26,7 @@ impl builtins::SimpleCommand for TrueCommand {
|
||||
builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()),
|
||||
builtins::ContentType::ShortUsage => Ok("true".into()),
|
||||
builtins::ContentType::ShortDescription => Ok("true - success".into()),
|
||||
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
|
||||
builtins::ContentType::ManPage => Ok(MAN_PAGE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ impl builtins::Command for UmaskCommand {
|
||||
let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?;
|
||||
set_umask(parsed)?;
|
||||
} else {
|
||||
return brush_core::error::unimp("umask setting mode from symbolic value");
|
||||
let current_umask = get_umask()?;
|
||||
let parsed = parse_symbolic_umask(mode, current_umask)?;
|
||||
set_umask(parsed)?;
|
||||
}
|
||||
} else {
|
||||
let umask = get_umask()?;
|
||||
@@ -74,6 +76,81 @@ cfg_if! {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_symbolic_umask(mode: &str, current_umask: u32) -> Result<nix::sys::stat::mode_t, brush_core::Error> {
|
||||
let mut umask = current_umask & 0o777;
|
||||
let mut chars = mode.chars().peekable();
|
||||
let mut saw_clause = false;
|
||||
|
||||
while chars.peek().is_some() {
|
||||
saw_clause = true;
|
||||
|
||||
let mut who_bits = 0;
|
||||
while let Some(&ch) = chars.peek() {
|
||||
let bits = match ch {
|
||||
'u' => 0o700,
|
||||
'g' => 0o070,
|
||||
'o' => 0o007,
|
||||
'a' => 0o777,
|
||||
_ => break,
|
||||
};
|
||||
who_bits |= bits;
|
||||
chars.next();
|
||||
}
|
||||
if who_bits == 0 {
|
||||
who_bits = 0o777;
|
||||
}
|
||||
|
||||
loop {
|
||||
let op = chars.next().ok_or(ErrorKind::InvalidUmask)?;
|
||||
if !matches!(op, '+' | '-' | '=') {
|
||||
return Err(ErrorKind::InvalidUmask.into());
|
||||
}
|
||||
|
||||
let mut perm_bits = 0;
|
||||
while let Some(&ch) = chars.peek() {
|
||||
let bits = match ch {
|
||||
'r' => 0o444,
|
||||
'w' => 0o222,
|
||||
'x' => 0o111,
|
||||
'+' | '-' | '=' | ',' => break,
|
||||
_ => return Err(ErrorKind::InvalidUmask.into()),
|
||||
};
|
||||
perm_bits |= bits & who_bits;
|
||||
chars.next();
|
||||
}
|
||||
|
||||
match op {
|
||||
'+' => umask &= !perm_bits,
|
||||
'-' => umask |= perm_bits,
|
||||
'=' => {
|
||||
umask |= who_bits;
|
||||
umask &= !perm_bits;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
match chars.peek() {
|
||||
Some(',') => {
|
||||
chars.next();
|
||||
if chars.peek().is_none() {
|
||||
return Err(ErrorKind::InvalidUmask.into());
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some('+' | '-' | '=') => continue,
|
||||
Some(_) => return Err(ErrorKind::InvalidUmask.into()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_clause {
|
||||
Ok(umask as nix::sys::stat::mode_t)
|
||||
} else {
|
||||
Err(ErrorKind::InvalidUmask.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> {
|
||||
// value of mode_t can be platform dependent
|
||||
let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?;
|
||||
@@ -96,3 +173,39 @@ fn symbolic_mask_from_bits(bits: u32) -> String {
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(mode: &str, current_umask: u32) -> u32 {
|
||||
parse_symbolic_umask(mode, current_umask).unwrap() as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_symbolic_umask_assignments() {
|
||||
assert_eq!(parse("u=rwx,g=rx,o=", 0o022), 0o027);
|
||||
assert_eq!(parse("=r", 0o022), 0o333);
|
||||
assert_eq!(parse("a=", 0o022), 0o777);
|
||||
assert_eq!(parse("u=", 0o022), 0o722);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_symbolic_umask_incremental_ops() {
|
||||
assert_eq!(parse("u+rw", 0o777), 0o177);
|
||||
assert_eq!(parse("g-w", 0o022), 0o022);
|
||||
assert_eq!(parse("+x", 0o022), 0o022);
|
||||
assert_eq!(parse("u+r-w", 0o777), 0o377);
|
||||
assert_eq!(parse("a+r,u-w", 0o777), 0o333);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_symbolic_umasks() {
|
||||
assert!(parse_symbolic_umask("", 0o022).is_err());
|
||||
assert!(parse_symbolic_umask("u", 0o022).is_err());
|
||||
assert!(parse_symbolic_umask("u+z", 0o022).is_err());
|
||||
assert!(parse_symbolic_umask("z+r", 0o022).is_err());
|
||||
assert!(parse_symbolic_umask("u=,", 0o022).is_err());
|
||||
assert!(parse_symbolic_umask("u,,g=r", 0o022).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,12 @@ impl builtins::Command for UnsetCommand {
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<brush_core::ExecutionResult, Self::Error> {
|
||||
//
|
||||
// TODO(nameref): implement nameref
|
||||
//
|
||||
if self.name_interpretation.name_references {
|
||||
return brush_core::error::unimp("unset: name references are not yet implemented");
|
||||
for name in &self.names {
|
||||
unset_name_reference(context.shell, name)?;
|
||||
}
|
||||
|
||||
return Ok(ExecutionResult::success());
|
||||
}
|
||||
|
||||
let unspecified = self.name_interpretation.unspecified();
|
||||
@@ -91,6 +92,27 @@ impl builtins::Command for UnsetCommand {
|
||||
Ok(ExecutionResult::success())
|
||||
}
|
||||
}
|
||||
fn unset_name_reference(
|
||||
shell: &mut Shell<impl brush_core::ShellExtensions>,
|
||||
name: &str,
|
||||
) -> Result<bool, brush_core::Error> {
|
||||
let Ok(brush_parser::word::Parameter::Named(name)) =
|
||||
brush_parser::word::parse_parameter(name, &shell.parser_options())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if shell
|
||||
.env()
|
||||
.get(name.as_str())
|
||||
.is_some_and(|(_, var)| var.is_treated_as_nameref())
|
||||
{
|
||||
shell.env_mut().unset(name.as_str()).map(|v| v.is_some())
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn unset_array_index(
|
||||
shell: &mut Shell<impl brush_core::ShellExtensions>,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use std::io::Write;
|
||||
|
||||
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error};
|
||||
use brush_core::{
|
||||
ExecutionExitCode, ExecutionResult, builtins,
|
||||
env::{EnvironmentLookup, EnvironmentScope},
|
||||
int_utils,
|
||||
jobs::{Job, JobSelector},
|
||||
variables::ShellValueLiteral,
|
||||
};
|
||||
use clap::Parser;
|
||||
|
||||
/// Wait for jobs to terminate.
|
||||
@@ -31,37 +37,74 @@ impl builtins::Command for WaitCommand {
|
||||
&self,
|
||||
context: brush_core::ExecutionContext<'_, SE>,
|
||||
) -> Result<ExecutionResult, Self::Error> {
|
||||
if self.wait_for_terminate {
|
||||
return error::unimp("wait -f");
|
||||
}
|
||||
if self.wait_for_first_or_next {
|
||||
return error::unimp("wait -n");
|
||||
}
|
||||
if self.variable_to_receive_id.is_some() {
|
||||
return error::unimp("wait -p");
|
||||
if let Some(variable) = &self.variable_to_receive_id {
|
||||
context.shell.env_mut().unset(variable)?;
|
||||
}
|
||||
|
||||
let mut result = ExecutionResult::success();
|
||||
|
||||
if self.wait_for_first_or_next {
|
||||
let selectors = match resolve_wait_selectors(&context, &self.ids)? {
|
||||
WaitSelectorResolution::Selectors(selectors) => selectors,
|
||||
WaitSelectorResolution::Failure(result) => return Ok(result),
|
||||
};
|
||||
let Some(waited) = context.shell.jobs_mut().wait_next(&selectors).await? else {
|
||||
return Ok(ExecutionExitCode::NotFound.into());
|
||||
};
|
||||
if let Some(variable) = &self.variable_to_receive_id {
|
||||
assign_wait_variable(context.shell, variable, waited.identifier)?;
|
||||
}
|
||||
return Ok(waited.result);
|
||||
}
|
||||
|
||||
let mut waited_identifier = None;
|
||||
if !self.ids.is_empty() {
|
||||
for id in &self.ids {
|
||||
if id.starts_with('%') {
|
||||
// It's a job spec.
|
||||
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) {
|
||||
job.wait().await?;
|
||||
waited_identifier = Some(job_identifier(job));
|
||||
result = if self.wait_for_terminate {
|
||||
job.wait_for_termination().await?
|
||||
} else {
|
||||
job.wait().await?
|
||||
};
|
||||
} else {
|
||||
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
|
||||
|
||||
result = ExecutionExitCode::GeneralError.into();
|
||||
}
|
||||
} else if let Ok(pid) = int_utils::parse::<i32>(id, 10) {
|
||||
if let Some(job) = context.shell.jobs_mut().resolve_process_id(pid) {
|
||||
waited_identifier = Some(pid.to_string());
|
||||
result = if self.wait_for_terminate {
|
||||
job.wait_for_termination().await?
|
||||
} else {
|
||||
job.wait().await?
|
||||
};
|
||||
} else {
|
||||
writeln!(
|
||||
context.stderr(),
|
||||
"{}: pid {pid} is not a child of this shell",
|
||||
context.command_name
|
||||
)?;
|
||||
|
||||
result = ExecutionExitCode::NotFound.into();
|
||||
}
|
||||
} else {
|
||||
// It's a process ID.
|
||||
return error::unimp("wait with process IDs");
|
||||
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
|
||||
|
||||
result = ExecutionExitCode::GeneralError.into();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wait for all jobs.
|
||||
let jobs = context.shell.jobs_mut().wait_all().await?;
|
||||
let jobs = if self.wait_for_terminate {
|
||||
context.shell.jobs_mut().wait_all_for_termination().await?
|
||||
} else {
|
||||
context.shell.jobs_mut().wait_all().await?
|
||||
};
|
||||
waited_identifier = jobs.last().map(job_identifier);
|
||||
|
||||
if context.shell.options().enable_job_control {
|
||||
for job in jobs {
|
||||
@@ -70,6 +113,71 @@ impl builtins::Command for WaitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(variable), Some(identifier)) = (&self.variable_to_receive_id, waited_identifier) {
|
||||
assign_wait_variable(context.shell, variable, identifier)?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
enum WaitSelectorResolution {
|
||||
Selectors(Vec<JobSelector>),
|
||||
Failure(ExecutionResult),
|
||||
}
|
||||
|
||||
fn resolve_wait_selectors<SE: brush_core::ShellExtensions>(
|
||||
context: &brush_core::ExecutionContext<'_, SE>,
|
||||
ids: &[String],
|
||||
) -> Result<WaitSelectorResolution, brush_core::Error> {
|
||||
let mut selectors = Vec::new();
|
||||
for id in ids {
|
||||
if id.starts_with('%') {
|
||||
if let Some(selector) = context.shell.jobs().resolve_job_spec_selector(id) {
|
||||
selectors.push(selector);
|
||||
} else {
|
||||
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
|
||||
return Ok(WaitSelectorResolution::Failure(
|
||||
ExecutionExitCode::GeneralError.into(),
|
||||
));
|
||||
}
|
||||
} else if let Ok(pid) = int_utils::parse::<i32>(id, 10) {
|
||||
if context.shell.jobs().contains_process_id(pid) {
|
||||
selectors.push(JobSelector::ProcessId(pid));
|
||||
} else {
|
||||
writeln!(
|
||||
context.stderr(),
|
||||
"{}: pid {pid} is not a child of this shell",
|
||||
context.command_name
|
||||
)?;
|
||||
return Ok(WaitSelectorResolution::Failure(ExecutionExitCode::NotFound.into()));
|
||||
}
|
||||
} else {
|
||||
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
|
||||
return Ok(WaitSelectorResolution::Failure(
|
||||
ExecutionExitCode::GeneralError.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(WaitSelectorResolution::Selectors(selectors))
|
||||
}
|
||||
|
||||
fn assign_wait_variable(
|
||||
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
|
||||
name: &str,
|
||||
value: String,
|
||||
) -> Result<(), brush_core::Error> {
|
||||
shell.env_mut().update_or_add(
|
||||
name,
|
||||
ShellValueLiteral::Scalar(value),
|
||||
|_| Ok(()),
|
||||
EnvironmentLookup::Anywhere,
|
||||
EnvironmentScope::Global,
|
||||
)
|
||||
}
|
||||
|
||||
fn job_identifier(job: &Job) -> String {
|
||||
job
|
||||
.representative_pid()
|
||||
.map_or_else(|| job.id.to_string(), |pid| pid.to_string())
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"sync",
|
||||
"time",
|
||||
]
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies.tokio]
|
||||
@@ -156,6 +157,7 @@ features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
"rt",
|
||||
"time",
|
||||
"sync",
|
||||
]
|
||||
|
||||
|
||||
@@ -174,8 +174,138 @@ impl<SE: extensions::ShellExtensions> Registration<SE> {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result<String, error::Error> {
|
||||
error::unimp("man page rendering is not yet implemented")
|
||||
fn get_builtin_man_page(name: &str, command: &clap::Command) -> Result<String, error::Error> {
|
||||
let mut man_page = String::new();
|
||||
|
||||
append_man_section(&mut man_page, "NAME");
|
||||
let description = command
|
||||
.get_about()
|
||||
.map_or_else(String::new, std::string::ToString::to_string);
|
||||
if description.is_empty() {
|
||||
man_page.push_str(name);
|
||||
man_page.push('\n');
|
||||
} else {
|
||||
man_page.push_str(name);
|
||||
man_page.push_str(" - ");
|
||||
man_page.push_str(&description);
|
||||
man_page.push('\n');
|
||||
}
|
||||
|
||||
append_man_section(&mut man_page, "SYNOPSIS");
|
||||
let mut usage_command = command.clone();
|
||||
let usage = usage_command.render_usage();
|
||||
man_page.push_str(&usage.to_string());
|
||||
man_page.push('\n');
|
||||
|
||||
if let Some(about) = command.get_long_about().or_else(|| command.get_about()) {
|
||||
append_man_section(&mut man_page, "DESCRIPTION");
|
||||
man_page.push_str(&about.to_string());
|
||||
man_page.push('\n');
|
||||
}
|
||||
|
||||
append_man_arguments_section(&mut man_page, command);
|
||||
append_man_options_section(&mut man_page, command);
|
||||
|
||||
Ok(man_page)
|
||||
}
|
||||
|
||||
fn append_man_section(buf: &mut String, title: &str) {
|
||||
if !buf.is_empty() {
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
buf.push_str(title);
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
fn append_man_arguments_section(buf: &mut String, command: &clap::Command) {
|
||||
let mut section_written = false;
|
||||
for arg in command.get_positionals() {
|
||||
if arg.is_hide_set() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !section_written {
|
||||
append_man_section(buf, "ARGUMENTS");
|
||||
section_written = true;
|
||||
}
|
||||
|
||||
write_man_arg_help(buf, &format_man_value_names(arg), arg);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_man_options_section(buf: &mut String, command: &clap::Command) {
|
||||
let mut section_written = false;
|
||||
for arg in command.get_opts() {
|
||||
if arg.is_hide_set() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !section_written {
|
||||
append_man_section(buf, "OPTIONS");
|
||||
section_written = true;
|
||||
}
|
||||
|
||||
write_man_arg_help(buf, &format_man_option(arg), arg);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_man_arg_help(buf: &mut String, label: &str, arg: &clap::Arg) {
|
||||
buf.push_str(" ");
|
||||
buf.push_str(label);
|
||||
buf.push('\n');
|
||||
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
|
||||
buf.push_str(" ");
|
||||
buf.push_str(&help.to_string());
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn format_man_option(arg: &clap::Arg) -> String {
|
||||
let mut option = String::new();
|
||||
if let Some(short) = arg.get_short() {
|
||||
option.push('-');
|
||||
option.push(short);
|
||||
}
|
||||
|
||||
if let Some(long) = arg.get_long() {
|
||||
if !option.is_empty() {
|
||||
option.push_str(", ");
|
||||
}
|
||||
|
||||
option.push_str("--");
|
||||
option.push_str(long);
|
||||
}
|
||||
|
||||
let values = format_man_value_names(arg);
|
||||
if !values.is_empty() {
|
||||
if !option.is_empty() {
|
||||
option.push(' ');
|
||||
}
|
||||
|
||||
option.push_str(&values);
|
||||
}
|
||||
|
||||
option
|
||||
}
|
||||
|
||||
fn format_man_value_names(arg: &clap::Arg) -> String {
|
||||
let Some(value_names) = arg.get_value_names() else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut values = String::new();
|
||||
for name in value_names {
|
||||
if !values.is_empty() {
|
||||
values.push(' ');
|
||||
}
|
||||
|
||||
values.push('<');
|
||||
values.push_str(name);
|
||||
values.push('>');
|
||||
}
|
||||
|
||||
values
|
||||
}
|
||||
|
||||
fn get_builtin_short_description(name: &str, command: &clap::Command) -> String {
|
||||
|
||||
@@ -814,8 +814,19 @@ pub(crate) async fn invoke_shell_function(
|
||||
|
||||
// Handle control-flow.
|
||||
match result.next_control_flow {
|
||||
ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => {
|
||||
return error::unimp("break or continue returned from function invocation");
|
||||
ExecutionControlFlow::BreakLoop { .. } => {
|
||||
writeln!(
|
||||
context.params.stderr(context.shell),
|
||||
"break: only meaningful in a `for', `while', or `until' loop"
|
||||
)?;
|
||||
result.next_control_flow = ExecutionControlFlow::Normal;
|
||||
},
|
||||
ExecutionControlFlow::ContinueLoop { .. } => {
|
||||
writeln!(
|
||||
context.params.stderr(context.shell),
|
||||
"continue: only meaningful in a `for', `while', or `until' loop"
|
||||
)?;
|
||||
result.next_control_flow = ExecutionControlFlow::Normal;
|
||||
},
|
||||
ExecutionControlFlow::ReturnFromFunctionOrScript => {
|
||||
// It's now been handled.
|
||||
|
||||
@@ -29,6 +29,10 @@ pub enum ErrorKind {
|
||||
#[error("cannot assign list to array member")]
|
||||
AssigningListToArrayMember,
|
||||
|
||||
/// An attempt was made to assign an associative array value without using a subscript.
|
||||
#[error("must use subscript when assigning associative array")]
|
||||
AssociativeArrayMissingSubscript,
|
||||
|
||||
/// An attempt was made to convert an associative array to an indexed array.
|
||||
#[error("cannot convert associative array to indexed array")]
|
||||
ConvertingAssociativeArrayToIndexedArray,
|
||||
|
||||
@@ -4,7 +4,7 @@ use brush_parser::ast;
|
||||
|
||||
use crate::{
|
||||
ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions,
|
||||
namedoptions, patterns,
|
||||
namedoptions, patterns, regex,
|
||||
sys::{
|
||||
fs::{MetadataExt, PathExt},
|
||||
users,
|
||||
@@ -160,7 +160,13 @@ pub(crate) fn apply_unary_predicate_to_str(
|
||||
Ok(md.gid() == users::get_effective_gid()?)
|
||||
},
|
||||
ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => {
|
||||
error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead")
|
||||
let path = shell.absolute_path(Path::new(operand));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let md = path.metadata()?;
|
||||
Ok(md.modified()? > md.accessed()?)
|
||||
},
|
||||
ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => {
|
||||
let path = shell.absolute_path(Path::new(operand));
|
||||
@@ -527,7 +533,15 @@ pub(crate) fn apply_binary_predicate_to_strs(
|
||||
},
|
||||
ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right),
|
||||
ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right),
|
||||
_ => error::unimp("unsupported test binary predicate"),
|
||||
ast::BinaryPredicate::StringContainsSubstring => Ok(left.contains(right)),
|
||||
ast::BinaryPredicate::StringMatchesRegex => {
|
||||
let re = regex::compile_regex(
|
||||
right.to_owned(),
|
||||
shell.options().case_insensitive_conditionals,
|
||||
true,
|
||||
)?;
|
||||
Ok(re.is_match(left)?)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1766,7 +1766,7 @@ async fn apply_assignment(
|
||||
existing_value.assign_at_index(array_index, s, assignment.append)?;
|
||||
},
|
||||
ShellValueLiteral::Array(_) => {
|
||||
return error::unimp("replacing an array item with an array");
|
||||
return Err(error::ErrorKind::AssigningListToArrayMember.into());
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@@ -1796,7 +1796,7 @@ async fn apply_assignment(
|
||||
ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)]))
|
||||
},
|
||||
ShellValueLiteral::Array(_) => {
|
||||
return error::unimp("cannot assign list to array member");
|
||||
return Err(error::ErrorKind::AssigningListToArrayMember.into());
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@@ -1917,7 +1917,10 @@ pub(crate) async fn setup_redirect(
|
||||
ast::IoFileRedirectKind::DuplicateInput => 0,
|
||||
ast::IoFileRedirectKind::DuplicateOutput => 1,
|
||||
_ => {
|
||||
return error::unimp("unexpected redirect kind");
|
||||
return Err(error::ErrorKind::InternalError(format!(
|
||||
"unexpected redirect kind for file descriptor target: {kind:?}"
|
||||
))
|
||||
.into());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1937,7 +1940,10 @@ pub(crate) async fn setup_redirect(
|
||||
ast::IoFileRedirectKind::DuplicateInput => 0,
|
||||
ast::IoFileRedirectKind::DuplicateOutput => 1,
|
||||
_ => {
|
||||
return error::unimp("unexpected redirect kind");
|
||||
return Err(error::ErrorKind::InternalError(format!(
|
||||
"unexpected redirect kind for duplicate target: {kind:?}"
|
||||
))
|
||||
.into());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2008,7 +2014,12 @@ pub(crate) async fn setup_redirect(
|
||||
|
||||
params.open_files.set_fd(fd_num, target_file);
|
||||
},
|
||||
_ => return error::unimp("invalid process substitution"),
|
||||
_ => {
|
||||
return Err(error::ErrorKind::InternalError(format!(
|
||||
"process substitution used with invalid redirect kind: {kind:?}"
|
||||
))
|
||||
.into());
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -2113,6 +2124,16 @@ fn setup_process_substitution(
|
||||
let mut child_params = params.clone();
|
||||
child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
|
||||
|
||||
// Starting at 63 (a.k.a. 64-1)--and decrementing--look for an
|
||||
// available fd before starting the substitution command.
|
||||
let mut candidate_fd_num = 63;
|
||||
while params.open_files.contains_fd(candidate_fd_num) {
|
||||
candidate_fd_num -= 1;
|
||||
if candidate_fd_num == 0 {
|
||||
return Err(error::ErrorKind::TooManyOpenFiles.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Set up pipe so we can connect to the command.
|
||||
let (reader, writer) = std::io::pipe()?;
|
||||
let (reader, writer) = (reader.into(), writer.into());
|
||||
@@ -2139,15 +2160,6 @@ fn setup_process_substitution(
|
||||
.await;
|
||||
});
|
||||
|
||||
// Starting at 63 (a.k.a. 64-1)--and decrementing--look for an
|
||||
// available fd.
|
||||
let mut candidate_fd_num = 63;
|
||||
while params.open_files.contains_fd(candidate_fd_num) {
|
||||
candidate_fd_num -= 1;
|
||||
if candidate_fd_num == 0 {
|
||||
return error::unimp("no available file descriptors");
|
||||
}
|
||||
}
|
||||
|
||||
Ok((candidate_fd_num, target_file))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Job management
|
||||
|
||||
use std::{collections::VecDeque, fmt::Display};
|
||||
use std::{collections::VecDeque, fmt::Display, time::Duration};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
@@ -12,6 +12,35 @@ use crate::{ExecutionResult, error, processes, sys, trace_categories, traps};
|
||||
pub(crate) type JobJoinHandle = tokio::task::JoinHandle<Result<ExecutionResult, error::Error>>;
|
||||
pub(crate) type JobResult = (Job, Result<ExecutionResult, error::Error>);
|
||||
|
||||
const WAIT_NEXT_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
|
||||
/// Selects a managed job by shell job ID or child process ID.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum JobSelector {
|
||||
/// Shell-internal job ID.
|
||||
JobId(usize),
|
||||
/// Child process ID.
|
||||
ProcessId(i32),
|
||||
}
|
||||
|
||||
/// Result returned when waiting for a single managed job.
|
||||
pub struct WaitedJob {
|
||||
/// Shell-internal job ID.
|
||||
pub id: usize,
|
||||
/// Process ID when known, otherwise the shell-internal job ID.
|
||||
pub identifier: String,
|
||||
/// Command line associated with the job.
|
||||
pub command_line: String,
|
||||
/// Exit status returned by the job.
|
||||
pub result: ExecutionResult,
|
||||
}
|
||||
|
||||
impl WaitedJob {
|
||||
fn from_job(job: Job, result: ExecutionResult, identifier: String) -> Self {
|
||||
Self { id: job.id, identifier, command_line: job.command_line, result }
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the jobs that are currently managed by the shell.
|
||||
#[derive(Default)]
|
||||
pub struct JobManager {
|
||||
@@ -42,17 +71,21 @@ impl JobTask {
|
||||
}
|
||||
|
||||
/// Waits for the task to complete. Returns the result of the wait.
|
||||
pub async fn wait(&mut self) -> Result<JobTaskWaitResult, error::Error> {
|
||||
pub async fn wait(
|
||||
&mut self,
|
||||
wait_for_terminate: bool,
|
||||
) -> Result<JobTaskWaitResult, error::Error> {
|
||||
match self {
|
||||
Self::External(process) => {
|
||||
Self::External(process) => loop {
|
||||
let wait_result = process.wait(None).await?;
|
||||
match wait_result {
|
||||
processes::ProcessWaitResult::Completed(output) => {
|
||||
Ok(JobTaskWaitResult::Completed(output.into()))
|
||||
break Ok(JobTaskWaitResult::Completed(output.into()));
|
||||
},
|
||||
processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped),
|
||||
processes::ProcessWaitResult::Stopped if wait_for_terminate => {},
|
||||
processes::ProcessWaitResult::Stopped => break Ok(JobTaskWaitResult::Stopped),
|
||||
processes::ProcessWaitResult::Cancelled => {
|
||||
Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130)))
|
||||
break Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130)));
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -162,15 +195,110 @@ impl JobManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to resolve the given job specification to a wait selector.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `job_spec` - The job specification to resolve.
|
||||
pub fn resolve_job_spec_selector(&self, job_spec: &str) -> Option<JobSelector> {
|
||||
let remainder = job_spec.strip_prefix('%')?;
|
||||
|
||||
match remainder {
|
||||
"%" | "+" => self.current_job().map(|job| JobSelector::JobId(job.id)),
|
||||
"-" => self.prev_job().map(|job| JobSelector::JobId(job.id)),
|
||||
s if s.chars().all(char::is_numeric) => {
|
||||
let id = s.parse::<usize>().ok()?;
|
||||
self
|
||||
.jobs
|
||||
.iter()
|
||||
.any(|job| job.id == id)
|
||||
.then_some(JobSelector::JobId(id))
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'");
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a managed job contains the given process ID.
|
||||
pub fn contains_process_id(&self, pid: i32) -> bool {
|
||||
self.jobs.iter().any(|job| job.contains_process_id(pid))
|
||||
}
|
||||
|
||||
/// Tries to resolve the given process ID to a managed job.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pid` - The process ID to resolve.
|
||||
pub fn resolve_process_id(&mut self, pid: i32) -> Option<&mut Job> {
|
||||
self.jobs.iter_mut().find(|job| job.contains_process_id(pid))
|
||||
}
|
||||
|
||||
/// Waits for all managed jobs to complete.
|
||||
pub async fn wait_all(&mut self) -> Result<Vec<Job>, error::Error> {
|
||||
self.wait_all_with_policy(false).await
|
||||
}
|
||||
|
||||
/// Waits for all managed jobs to terminate, ignoring stopped-state changes.
|
||||
pub async fn wait_all_for_termination(&mut self) -> Result<Vec<Job>, error::Error> {
|
||||
self.wait_all_with_policy(true).await
|
||||
}
|
||||
|
||||
async fn wait_all_with_policy(
|
||||
&mut self,
|
||||
wait_for_terminate: bool,
|
||||
) -> Result<Vec<Job>, error::Error> {
|
||||
for job in &mut self.jobs {
|
||||
job.wait().await?;
|
||||
job.wait_with_policy(wait_for_terminate).await?;
|
||||
}
|
||||
|
||||
Ok(self.sweep_completed_jobs())
|
||||
}
|
||||
|
||||
/// Waits for the next matching managed job to complete.
|
||||
pub async fn wait_next(
|
||||
&mut self,
|
||||
selectors: &[JobSelector],
|
||||
) -> Result<Option<WaitedJob>, error::Error> {
|
||||
loop {
|
||||
let mut found_candidate = false;
|
||||
let mut i = 0;
|
||||
while i != self.jobs.len() {
|
||||
if !selectors.is_empty()
|
||||
&& !selectors
|
||||
.iter()
|
||||
.any(|selector| self.jobs[i].matches_selector(*selector))
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_candidate = true;
|
||||
let identifier = self.jobs[i].wait_identifier();
|
||||
if let Some(result) = self.jobs[i].poll_done()? {
|
||||
let job = self.jobs.remove(i);
|
||||
return result.map(|result| Some(WaitedJob::from_job(job, result, identifier)));
|
||||
}
|
||||
if matches!(self.jobs[i].state, JobState::Done) {
|
||||
let job = self.jobs.remove(i);
|
||||
return Ok(Some(WaitedJob::from_job(
|
||||
job,
|
||||
ExecutionResult::success(),
|
||||
identifier,
|
||||
)));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if !found_candidate {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
tokio::time::sleep(WAIT_NEXT_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls all managed jobs for completion.
|
||||
pub fn poll(&mut self) -> Result<Vec<JobResult>, error::Error> {
|
||||
let mut results = vec![];
|
||||
@@ -371,10 +499,22 @@ impl Job {
|
||||
|
||||
/// Waits for the job to complete.
|
||||
pub async fn wait(&mut self) -> Result<ExecutionResult, error::Error> {
|
||||
self.wait_with_policy(false).await
|
||||
}
|
||||
|
||||
/// Waits for the job to terminate, ignoring stopped-state changes.
|
||||
pub async fn wait_for_termination(&mut self) -> Result<ExecutionResult, error::Error> {
|
||||
self.wait_with_policy(true).await
|
||||
}
|
||||
|
||||
async fn wait_with_policy(
|
||||
&mut self,
|
||||
wait_for_terminate: bool,
|
||||
) -> Result<ExecutionResult, error::Error> {
|
||||
let mut result = ExecutionResult::success();
|
||||
|
||||
while let Some(task) = self.tasks.back_mut() {
|
||||
match task.wait().await? {
|
||||
match task.wait(wait_for_terminate).await? {
|
||||
JobTaskWaitResult::Completed(execution_result) => {
|
||||
result = execution_result;
|
||||
self.tasks.pop_back();
|
||||
@@ -393,16 +533,17 @@ impl Job {
|
||||
|
||||
/// Moves the job to execute in the background.
|
||||
pub fn move_to_background(&mut self) -> Result<(), error::Error> {
|
||||
if matches!(self.state, JobState::Stopped) {
|
||||
if let Some(pgid) = self.process_group_id() {
|
||||
match &self.state {
|
||||
JobState::Stopped => {
|
||||
let pgid = self
|
||||
.process_group_id()
|
||||
.ok_or(error::ErrorKind::FailedToSendSignal)?;
|
||||
sys::signal::continue_process(pgid)?;
|
||||
self.state = JobState::Running;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error::ErrorKind::FailedToSendSignal.into())
|
||||
}
|
||||
} else {
|
||||
error::unimp("move job to background")
|
||||
},
|
||||
JobState::Running => Ok(()),
|
||||
JobState::Unknown | JobState::Done => Err(error::ErrorKind::FailedToSendSignal.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +598,26 @@ impl Job {
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_selector(&self, selector: JobSelector) -> bool {
|
||||
match selector {
|
||||
JobSelector::JobId(id) => self.id == id,
|
||||
JobSelector::ProcessId(pid) => self.contains_process_id(pid),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_process_id(&self, pid: i32) -> bool {
|
||||
self.tasks.iter().any(|task| match task {
|
||||
JobTask::External(process) => process.pid().is_some_and(|process_pid| process_pid == pid),
|
||||
JobTask::Internal(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_identifier(&self) -> String {
|
||||
self
|
||||
.representative_pid()
|
||||
.map_or_else(|| self.id.to_string(), |pid| pid.to_string())
|
||||
}
|
||||
|
||||
/// Tries to retrieve a "representative" pid for the job.
|
||||
pub fn representative_pid(&self) -> Option<sys::process::ProcessId> {
|
||||
for task in &self.tasks {
|
||||
|
||||
@@ -71,7 +71,7 @@ fn format_prompt_piece(
|
||||
return error::unimp("prompt: current command number");
|
||||
},
|
||||
brush_parser::prompt::PromptPiece::CurrentHistoryNumber => {
|
||||
return error::unimp("prompt: current history number");
|
||||
format_current_history_number(shell)
|
||||
},
|
||||
brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?,
|
||||
brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { tilde_replaced, basename } => {
|
||||
@@ -171,6 +171,13 @@ fn format_current_working_directory(
|
||||
working_dir_str
|
||||
}
|
||||
|
||||
fn format_current_history_number(shell: &Shell<impl extensions::ShellExtensions>) -> String {
|
||||
// Bash renders \! as the history number that will be assigned to the next
|
||||
// interactive command. When command history is disabled, bash keeps this at
|
||||
// 1 rather than rendering 0.
|
||||
shell.history().map_or(1, |history| history.count() + 1).to_string()
|
||||
}
|
||||
|
||||
fn format_time<Tz: chrono::TimeZone>(
|
||||
datetime: &chrono::DateTime<Tz>,
|
||||
format: &brush_parser::prompt::PromptTimeFormat,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Function support for shells.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use crate::{
|
||||
ExecutionParameters, commands, error, extensions, functions, results::ExecutionWaitResult,
|
||||
ExecutionParameters, commands, error, extensions, functions, jobs,
|
||||
results::{ExecutionResult, ExecutionWaitResult},
|
||||
};
|
||||
|
||||
impl<SE: extensions::ShellExtensions> crate::Shell<SE> {
|
||||
@@ -117,7 +120,20 @@ impl<SE: extensions::ShellExtensions> crate::Shell<SE> {
|
||||
|
||||
match result.wait_with_cancel(params.cancel_token()).await? {
|
||||
ExecutionWaitResult::Completed(result) => Ok(result.exit_code.into()),
|
||||
ExecutionWaitResult::Stopped(..) => error::unimp("stopped child from function invocation"),
|
||||
ExecutionWaitResult::Stopped(child) => {
|
||||
let result = ExecutionResult::stopped();
|
||||
let job = self.jobs_mut().add_as_current(jobs::Job::new(
|
||||
[jobs::JobTask::External(child)],
|
||||
name.to_owned(),
|
||||
jobs::JobState::Stopped,
|
||||
));
|
||||
let formatted = job.to_string();
|
||||
|
||||
// N.B. We use the '\r' to overwrite any ^Z output.
|
||||
writeln!(params.stderr(self), "\r{formatted}")?;
|
||||
|
||||
Ok(result.exit_code.into())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Shell, error, extensions, interp};
|
||||
use crate::{Shell, error, expansion, extensions, interp};
|
||||
|
||||
/// Behavior for loading profile files.
|
||||
#[derive(Default)]
|
||||
@@ -130,14 +130,24 @@ impl<SE: extensions::ShellExtensions> Shell<SE> {
|
||||
"BASH_ENV"
|
||||
};
|
||||
|
||||
if self.env.is_set(env_var_name) {
|
||||
//
|
||||
// TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that
|
||||
// file exists
|
||||
//
|
||||
return error::unimp(
|
||||
"load config from $ENV/BASH_ENV for non-interactive, non-login shell",
|
||||
);
|
||||
if let Some(config_path) = self.env_str(env_var_name) {
|
||||
let config_path = config_path.into_owned();
|
||||
let options = expansion::ExpanderOptions {
|
||||
brace_expand: false,
|
||||
pathname_expand: false,
|
||||
..Default::default()
|
||||
};
|
||||
let expanded_path = expansion::basic_expand_word_with_options(
|
||||
self,
|
||||
¶ms,
|
||||
config_path.as_str(),
|
||||
&options,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !expanded_path.is_empty() {
|
||||
self.source_if_exists(PathBuf::from(expanded_path), ¶ms).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,10 +437,8 @@ impl ShellVariable {
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
_ => {
|
||||
tracing::error!("assigning to index {array_index} of {:?}", self.value);
|
||||
error::unimp("assigning to index of non-array variable")
|
||||
},
|
||||
ShellValue::Dynamic { .. } => Ok(()),
|
||||
ShellValue::Unset(_) | ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,23 +798,45 @@ impl ShellValue {
|
||||
existing_values: &mut BTreeMap<String, String>,
|
||||
literal_values: ArrayLiteral,
|
||||
) -> Result<(), error::Error> {
|
||||
let mut current_key = None;
|
||||
for (key, value) in literal_values.0 {
|
||||
if let Some(current_key) = current_key.take() {
|
||||
if key.is_some() {
|
||||
return error::unimp("misaligned keys/values in associative array literal");
|
||||
} else {
|
||||
existing_values.insert(current_key, value);
|
||||
}
|
||||
} else if let Some(key) = key {
|
||||
existing_values.insert(key, value);
|
||||
} else {
|
||||
current_key = Some(value);
|
||||
}
|
||||
}
|
||||
let mut literal_values = literal_values.0.into_iter();
|
||||
let Some((first_key, first_value)) = literal_values.next() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(current_key) = current_key {
|
||||
existing_values.insert(current_key, String::new());
|
||||
if let Some(first_key) = first_key {
|
||||
existing_values.insert(first_key, first_value);
|
||||
|
||||
for (key, value) in literal_values {
|
||||
let Some(key) = key else {
|
||||
return Err(error::ErrorKind::AssociativeArrayMissingSubscript.into());
|
||||
};
|
||||
|
||||
existing_values.insert(key, value);
|
||||
}
|
||||
} else {
|
||||
let mut current_key = Some(first_value);
|
||||
for (key, value) in literal_values {
|
||||
let value = if let Some(key) = key {
|
||||
let mut word = String::with_capacity(key.len() + value.len() + 3);
|
||||
word.push('[');
|
||||
word.push_str(key.as_str());
|
||||
word.push_str("]=");
|
||||
word.push_str(value.as_str());
|
||||
word
|
||||
} else {
|
||||
value
|
||||
};
|
||||
|
||||
if let Some(key) = current_key.take() {
|
||||
existing_values.insert(key, value);
|
||||
} else {
|
||||
current_key = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(current_key) = current_key {
|
||||
existing_values.insert(current_key, String::new());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+197
-1
@@ -8,10 +8,12 @@
|
||||
//! full span; pointing at a continuation line or a lone closing delimiter
|
||||
//! resolves to nothing.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use ast_grep_core::tree_sitter::LanguageExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tree_sitter::{Parser, Point};
|
||||
use tree_sitter::{Parser, Point, TreeCursor};
|
||||
|
||||
use crate::summary::{node_content_end_line, node_start_line, resolve_language};
|
||||
|
||||
@@ -111,6 +113,141 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result<Option<BlockRange>>
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LineRange {
|
||||
/// 1-indexed inclusive first visible line.
|
||||
pub start_line: u32,
|
||||
/// 1-indexed inclusive last visible line.
|
||||
pub end_line: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnclosingBoundaryOptions {
|
||||
/// Source code to inspect.
|
||||
pub code: String,
|
||||
/// Language alias (e.g. "rust", "typescript") used before path inference.
|
||||
pub lang: Option<String>,
|
||||
/// File path used to infer language by extension when `lang` is omitted.
|
||||
pub path: Option<String>,
|
||||
/// 1-indexed inclusive visible line ranges (the lines actually shown).
|
||||
pub ranges: Vec<LineRange>,
|
||||
}
|
||||
|
||||
/// Sort, drop invalid, and merge adjacent/overlapping ranges so visibility
|
||||
/// tests can binary-search a non-overlapping list.
|
||||
fn normalize_ranges(mut ranges: Vec<LineRange>) -> Vec<LineRange> {
|
||||
ranges.retain(|range| range.start_line > 0 && range.end_line >= range.start_line);
|
||||
ranges.sort_by(|a, b| {
|
||||
a.start_line
|
||||
.cmp(&b.start_line)
|
||||
.then(a.end_line.cmp(&b.end_line))
|
||||
});
|
||||
let mut merged: Vec<LineRange> = Vec::with_capacity(ranges.len());
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& range.start_line <= last.end_line.saturating_add(1)
|
||||
{
|
||||
last.end_line = last.end_line.max(range.end_line);
|
||||
continue;
|
||||
}
|
||||
merged.push(range);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn is_visible(merged: &[LineRange], line: u32) -> bool {
|
||||
merged
|
||||
.binary_search_by(|range| {
|
||||
if line < range.start_line {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if line > range.end_line {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Depth-first walk collecting boundary lines from every multi-line named node
|
||||
/// that straddles a visible-range edge. A single reused [`TreeCursor`] keeps
|
||||
/// the traversal allocation-free.
|
||||
fn collect_boundaries(cursor: &mut TreeCursor<'_>, merged: &[LineRange], out: &mut BTreeSet<u32>) {
|
||||
let node = cursor.node();
|
||||
// Skip the whole-file root: its only "boundary" is EOF, never a useful
|
||||
// matching line (mirrors `block_range_at` excluding the root).
|
||||
if node.is_named() && node.parent().is_some() {
|
||||
let start = node_start_line(node);
|
||||
let end = node_content_end_line(node);
|
||||
if end > start {
|
||||
let start_visible = is_visible(merged, start);
|
||||
let end_visible = is_visible(merged, end);
|
||||
// Opener shown, closer off-window → surface the closer (and vice
|
||||
// versa). A node fully inside or fully outside the window adds
|
||||
// nothing.
|
||||
if start_visible && !end_visible {
|
||||
out.insert(end);
|
||||
} else if end_visible && !start_visible {
|
||||
out.insert(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cursor.goto_first_child() {
|
||||
loop {
|
||||
collect_boundaries(cursor, merged, out);
|
||||
if !cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor.goto_parent();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generalize "show the matching bracket" to every tree-sitter block: for each
|
||||
/// multi-line named node whose span crosses the visible window, return the
|
||||
/// boundary line sitting *outside* that window.
|
||||
///
|
||||
/// - node opens on a visible line but closes past the window → its closing line
|
||||
/// - node closes on a visible line but opens before the window → its opening
|
||||
/// line
|
||||
///
|
||||
/// Because the trigger is an endpoint *inside* the window, the result is
|
||||
/// bounded by the window size (not nesting depth), exactly like a bracket scan
|
||||
/// — but it also covers indentation languages (Python) and uses real syntactic
|
||||
/// spans.
|
||||
///
|
||||
/// Returns `None` when the language is unrecognized or the source fails to
|
||||
/// parse / carries a syntax error (caller falls back to a lexical bracket
|
||||
/// scan); `Some(sorted unique boundary lines)` otherwise (possibly empty).
|
||||
pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result<Option<Vec<u32>>> {
|
||||
let EnclosingBoundaryOptions { code, lang, path, ranges } = options;
|
||||
let merged = normalize_ranges(ranges);
|
||||
if code.is_empty() || merged.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let Some(language) = resolve_language(lang.as_deref(), path.as_deref()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut parser = Parser::new();
|
||||
parser
|
||||
.set_language(&language.get_ts_language())
|
||||
.map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?;
|
||||
let Some(tree) = parser.parse(&code, None) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let root = tree.root_node();
|
||||
// A file-level syntax error makes error-recovery spans unreliable; defer to
|
||||
// the lexical scanner rather than emit boundaries off a broken tree.
|
||||
if root.has_error() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut boundaries = BTreeSet::new();
|
||||
let mut cursor = root.walk();
|
||||
collect_boundaries(&mut cursor, &merged, &mut boundaries);
|
||||
Ok(Some(boundaries.into_iter().collect()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -220,4 +357,63 @@ mod tests {
|
||||
let code = "struct A;\nstruct B {\n x: u32,\n}\n";
|
||||
assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 }));
|
||||
}
|
||||
|
||||
fn boundaries(code: &str, path: &str, ranges: &[(u32, u32)]) -> Option<Vec<u32>> {
|
||||
enclosing_block_boundaries(EnclosingBoundaryOptions {
|
||||
code: code.to_string(),
|
||||
lang: None,
|
||||
path: Some(path.to_string()),
|
||||
ranges: ranges
|
||||
.iter()
|
||||
.map(|&(start_line, end_line)| LineRange { start_line, end_line })
|
||||
.collect(),
|
||||
})
|
||||
.expect("boundary resolution succeeds")
|
||||
}
|
||||
|
||||
const TS_FN: &str = "function outer() {\n const a = 1;\n const b = 2;\n const c = 3;\n \
|
||||
return a + b + c;\n}\nafter();\n";
|
||||
|
||||
#[test]
|
||||
fn surfaces_closing_brace_for_visible_opener() {
|
||||
// Window is the opening line only; its block closes on line 6.
|
||||
assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 1)]), Some(vec![6]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfaces_opening_brace_for_visible_closer() {
|
||||
// Window is the closing line only; its block opens on line 1.
|
||||
assert_eq!(boundaries(TS_FN, "x.ts", &[(6, 6)]), Some(vec![1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_only_window_adds_no_boundary() {
|
||||
// Neither the opener (1) nor the closer (6) is visible, so the bracket
|
||||
// scan would add nothing — and neither do we.
|
||||
assert_eq!(boundaries(TS_FN, "x.ts", &[(3, 4)]), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_window_adds_no_boundary() {
|
||||
assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 7)]), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_indentation_block_uses_syntactic_span() {
|
||||
// Python has no closing delimiter — the def's span ends at the last
|
||||
// body line. Showing the `def` header surfaces that end line.
|
||||
let code = "def greet(name):\n a = 1\n b = 2\n return a + b\n";
|
||||
assert_eq!(boundaries(code, "g.py", &[(1, 1)]), Some(vec![4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_error_falls_back_to_none() {
|
||||
let code = "function broken() {\n if (y) {\n";
|
||||
assert_eq!(boundaries(code, "b.ts", &[(1, 1)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_language_falls_back_to_none() {
|
||||
assert_eq!(boundaries(TS_FN, "x.unknownext", &[(1, 1)]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,45 @@ pub struct AstFindResult {
|
||||
pub parse_errors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Options for `astMatch`: run ast-grep patterns against an in-memory source
|
||||
/// string instead of files on disk.
|
||||
#[napi(object)]
|
||||
pub struct AstMatchOptions<'env> {
|
||||
/// Source code to match against (parsed in memory, never read from disk).
|
||||
pub source: String,
|
||||
/// Language of `source` (required; e.g. "ts", "tsx", "rust", "python").
|
||||
pub lang: String,
|
||||
/// ast-grep patterns to search for (OR across patterns).
|
||||
pub patterns: Vec<String>,
|
||||
/// Rule selector for multi-rule ast-grep configurations.
|
||||
pub selector: Option<String>,
|
||||
/// Pattern strictness; defaults to smart matching when omitted.
|
||||
pub strictness: Option<AstMatchStrictness>,
|
||||
/// Maximum matches to return after `offset` (default applies when omitted).
|
||||
pub limit: Option<u32>,
|
||||
/// Number of leading matches to skip before applying `limit`.
|
||||
pub offset: Option<u32>,
|
||||
/// When true, include meta-variable bindings per match.
|
||||
pub include_meta: Option<bool>,
|
||||
/// Optional cancellation handle (library-specific).
|
||||
pub signal: Option<Unknown<'env>>,
|
||||
/// Wall-clock timeout for the worker task in milliseconds.
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Result of an in-memory `astMatch` run.
|
||||
#[napi(object)]
|
||||
pub struct AstMatchResult {
|
||||
/// Page of matches after sort, offset, and limit.
|
||||
pub matches: Vec<AstFindMatch>,
|
||||
/// Total matches found before paging (can exceed `matches.length`).
|
||||
pub total_matches: u32,
|
||||
/// True when results were truncated by `limit`.
|
||||
pub limit_reached: bool,
|
||||
/// Non-fatal parse or pattern-compile errors collected during the run.
|
||||
pub parse_errors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Options for `astEdit`: rewrite rules, scan scope, safety limits, and
|
||||
/// dry-run.
|
||||
#[napi(object)]
|
||||
@@ -688,6 +727,117 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise<AstFindResult> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Match ast-grep patterns against an in-memory source string; returns a
|
||||
/// promise resolved on a worker thread.
|
||||
///
|
||||
/// This is the file-free counterpart to [`ast_grep`]: callers that already hold
|
||||
/// the source (streaming buffers, generated code, editor contents) avoid a
|
||||
/// temp-file round trip. `lang` is required since there is no path to infer it
|
||||
/// from.
|
||||
#[napi]
|
||||
pub fn ast_match(options: AstMatchOptions<'_>) -> task::Promise<AstMatchResult> {
|
||||
let AstMatchOptions {
|
||||
source,
|
||||
lang,
|
||||
patterns,
|
||||
selector,
|
||||
strictness,
|
||||
limit,
|
||||
offset,
|
||||
include_meta,
|
||||
signal,
|
||||
timeout_ms,
|
||||
} = options;
|
||||
|
||||
let ct = task::CancelToken::new(timeout_ms, signal);
|
||||
let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1);
|
||||
let normalized_offset = offset.unwrap_or(0);
|
||||
|
||||
task::blocking("ast_match", ct, move |ct| {
|
||||
let patterns = normalize_pattern_list(Some(patterns))?;
|
||||
let strictness = resolve_strictness(strictness);
|
||||
let include_meta = include_meta.unwrap_or(false);
|
||||
let lang_str = lang.trim();
|
||||
if lang_str.is_empty() {
|
||||
return Err(Error::from_reason("`lang` is required for ast_match".to_string()));
|
||||
}
|
||||
let language = resolve_supported_lang(lang_str)?;
|
||||
|
||||
let mut parse_errors = Vec::new();
|
||||
let mut compiled_patterns = Vec::with_capacity(patterns.len());
|
||||
for pattern in &patterns {
|
||||
ct.heartbeat()?;
|
||||
match compile_pattern(pattern, selector.as_deref(), &strictness, language) {
|
||||
Ok(compiled) => compiled_patterns.push(compiled),
|
||||
Err(err) => parse_errors.push(format!("{pattern}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_matches = Vec::new();
|
||||
let mut total_matches = 0u32;
|
||||
if !compiled_patterns.is_empty() {
|
||||
let ast = language.ast_grep(&source);
|
||||
if ast.root().dfs().any(|node| node.is_error()) {
|
||||
parse_errors.push("parse error (syntax tree contains error nodes)".to_string());
|
||||
}
|
||||
for pattern in &compiled_patterns {
|
||||
ct.heartbeat()?;
|
||||
for matched in ast.root().find_all(pattern.clone()) {
|
||||
ct.heartbeat()?;
|
||||
total_matches = total_matches.saturating_add(1);
|
||||
let range = matched.range();
|
||||
let start = matched.start_pos();
|
||||
let end = matched.end_pos();
|
||||
let meta_variables = if include_meta {
|
||||
Some(HashMap::<String, String>::from(matched.get_env().clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
all_matches.push(AstFindMatch {
|
||||
path: String::new(),
|
||||
text: matched.text().into_owned(),
|
||||
byte_start: to_u32(range.start),
|
||||
byte_end: to_u32(range.end),
|
||||
start_line: to_u32(start.line().saturating_add(1)),
|
||||
start_column: to_u32(start.column(matched.get_node()).saturating_add(1)),
|
||||
end_line: to_u32(end.line().saturating_add(1)),
|
||||
end_column: to_u32(end.column(matched.get_node()).saturating_add(1)),
|
||||
meta_variables,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_matches.sort_by(|left, right| {
|
||||
left
|
||||
.start_line
|
||||
.cmp(&right.start_line)
|
||||
.then(left.start_column.cmp(&right.start_column))
|
||||
.then(left.end_line.cmp(&right.end_line))
|
||||
.then(left.end_column.cmp(&right.end_column))
|
||||
.then(left.byte_start.cmp(&right.byte_start))
|
||||
.then(left.byte_end.cmp(&right.byte_end))
|
||||
});
|
||||
|
||||
let visible_matches = all_matches
|
||||
.into_iter()
|
||||
.skip(normalized_offset as usize)
|
||||
.collect::<Vec<_>>();
|
||||
let limit_reached = visible_matches.len() > normalized_limit as usize;
|
||||
let matches = visible_matches
|
||||
.into_iter()
|
||||
.take(normalized_limit as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(AstMatchResult {
|
||||
matches,
|
||||
total_matches,
|
||||
limit_reached,
|
||||
parse_errors: (!parse_errors.is_empty()).then_some(parse_errors),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns
|
||||
/// a promise.
|
||||
#[napi]
|
||||
|
||||
@@ -45,3 +45,51 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result<Option<BlockRange>>
|
||||
.map(|range| range.map(Into::into))
|
||||
.map_err(|error| Error::from_reason(error.to_string()))
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LineRange {
|
||||
/// 1-indexed inclusive first visible line.
|
||||
pub start_line: u32,
|
||||
/// 1-indexed inclusive last visible line.
|
||||
pub end_line: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct EnclosingBoundaryOptions {
|
||||
/// Source code to inspect.
|
||||
pub code: String,
|
||||
/// Language alias (e.g. "rust", "typescript") used before path inference.
|
||||
pub lang: Option<String>,
|
||||
/// File path used to infer language by extension when `lang` is omitted.
|
||||
pub path: Option<String>,
|
||||
/// 1-indexed inclusive visible line ranges (the lines actually shown).
|
||||
pub ranges: Vec<LineRange>,
|
||||
}
|
||||
|
||||
/// Matching-bracket context for an arbitrary tree-sitter language.
|
||||
///
|
||||
/// For each multi-line named node whose span crosses the visible window, return
|
||||
/// the boundary line sitting *outside* that window (the closer when the opener
|
||||
/// is shown, the opener when the closer is shown). Covers brace and indentation
|
||||
/// languages alike using real syntactic spans.
|
||||
///
|
||||
/// Returns `null` when the language is unrecognized or the source fails to
|
||||
/// parse / carries a syntax error (caller should fall back to a lexical scan);
|
||||
/// a sorted, unique list of 1-indexed boundary lines otherwise.
|
||||
#[napi]
|
||||
pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result<Option<Vec<u32>>> {
|
||||
pi_ast::block::enclosing_block_boundaries(pi_ast::block::EnclosingBoundaryOptions {
|
||||
code: options.code,
|
||||
lang: options.lang,
|
||||
path: options.path,
|
||||
ranges: options
|
||||
.ranges
|
||||
.into_iter()
|
||||
.map(|range| pi_ast::block::LineRange {
|
||||
start_line: range.start_line,
|
||||
end_line: range.end_line,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.map_err(|error| Error::from_reason(error.to_string()))
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@ use napi_derive::napi;
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV15_10_4")]
|
||||
#[napi(js_name = "__piNativesV15_10_5")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
@@ -490,6 +490,7 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
|
||||
}
|
||||
shell.register_builtin("sleep", builtins::builtin::<SleepCommand, _>());
|
||||
shell.register_builtin("timeout", builtins::builtin::<TimeoutCommand, _>());
|
||||
shell.register_builtin("nohup", builtins::builtin::<NohupCommand, _>());
|
||||
|
||||
let mut merged_path: Option<String> = None;
|
||||
for (key, value) in std::env::vars() {
|
||||
@@ -1573,6 +1574,61 @@ impl builtins::Command for TimeoutCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(disable_help_flag = true)]
|
||||
struct NohupCommand {
|
||||
#[arg(num_args = 0.., trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
impl builtins::Command for NohupCommand {
|
||||
type Error = brush_core::Error;
|
||||
|
||||
fn execute<SE: brush_core::ShellExtensions>(
|
||||
&self,
|
||||
context: ExecutionContext<'_, SE>,
|
||||
) -> impl Future<Output = std::result::Result<ExecutionResult, brush_core::Error>> + Send {
|
||||
let command = self.command.clone();
|
||||
async move {
|
||||
if context.is_cancelled() {
|
||||
return Ok(ExecutionExitCode::Interrupted.into());
|
||||
}
|
||||
// coreutils `nohup` with no operand fails with exit code 125.
|
||||
if command.is_empty() {
|
||||
let _ = writeln!(context.stderr(), "nohup: missing operand");
|
||||
return Ok(ExecutionResult::new(125));
|
||||
}
|
||||
|
||||
// Deliberately *not* nohup: we neither ignore SIGHUP nor detach the
|
||||
// child into a new session. The command runs as an ordinary brush
|
||||
// descendant so it is reaped together with the host instead of
|
||||
// lingering as an orphan once the host process goes away. Agents
|
||||
// reach for `nohup` assuming the shell is one-shot; in this
|
||||
// persistent embedded shell that assumption is wrong and the only
|
||||
// effect of real `nohup` would be to leak background processes.
|
||||
//
|
||||
// coreutils `nohup` additionally redirects stdin from /dev/null and
|
||||
// stdout/stderr to `nohup.out`, but *only* when those streams are
|
||||
// terminals. The embedded host always hands commands a pipe with a
|
||||
// /dev/null stdin, so none of that redirection ever applies here.
|
||||
let mut command_line = String::new();
|
||||
for (idx, arg) in command.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
command_line.push(' ');
|
||||
}
|
||||
command_line.push_str("e_arg(arg));
|
||||
}
|
||||
|
||||
let params = context.params.clone();
|
||||
let source_info = SourceInfo::from("pi-natives:nohup");
|
||||
context
|
||||
.shell
|
||||
.run_string(command_line, &source_info, ¶ms)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse_duration(input: &str) -> Option<Duration> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1918,6 +1974,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_accepts_last_background_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
|
||||
assert_eq!(result.exit_code, Some(7));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_n_p_records_completed_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \
|
||||
wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \
|
||||
-eq 13 ] && [ \"$hit\" = \"$fast\" ]"
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_f_accepts_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
|
||||
assert_eq!(result.exit_code, Some(5));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abort_state_signals_cancel_token() {
|
||||
let abort_state = ShellAbortState::default();
|
||||
@@ -2129,4 +2236,84 @@ mod tests {
|
||||
|
||||
assert_eq!(result.exit_code, Some(0), "command did not run to completion");
|
||||
}
|
||||
|
||||
/// The `nohup` builtin runs its operand command and surfaces that command's
|
||||
/// own exit status — not nohup's (`125`/`126`/`127`) error codes.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nohup_builtin_propagates_command_exit_code() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "nohup /bin/sh -c 'exit 7'".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
assert_eq!(result.exit_code, Some(7));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
/// `nohup` with no operand mirrors coreutils: a `missing operand` diagnostic
|
||||
/// and exit code 125 (a nohup-level error, distinct from any command code).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nohup_builtin_without_command_reports_missing_operand() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let options = ShellExecuteOptions { command: "nohup".to_string(), ..Default::default() };
|
||||
let result = execute_shell(options, Some(tx), CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
assert_eq!(result.exit_code, Some(125));
|
||||
let mut out = String::new();
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
out.push_str(&chunk);
|
||||
}
|
||||
assert!(
|
||||
out.contains("missing operand"),
|
||||
"expected a missing-operand diagnostic, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The contract that makes this a *builtin* and not the external tool: the
|
||||
/// child must **not** inherit `SIGHUP = SIG_IGN`. Real `nohup` masks SIGHUP
|
||||
/// (and it survives `exec`), so a process launched through `/usr/bin/nohup`
|
||||
/// reports `IGN` here; the builtin runs the command as an ordinary
|
||||
/// descendant, so it reports `DFL` and dies with the host on hangup. The
|
||||
/// probe needs `getsid`-style signal introspection, so it is gated on
|
||||
/// `python3` (skipped, not failed, when absent — matching the embedded
|
||||
/// session-detach e2e suite).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nohup_builtin_does_not_mask_sighup() {
|
||||
let python_ok = std::process::Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg("pass")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !python_ok {
|
||||
eprintln!("skipping nohup_builtin_does_not_mask_sighup: python3 unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let probe = "import signal,sys; sys.stdout.write('IGN' if \
|
||||
signal.getsignal(signal.SIGHUP)==signal.SIG_IGN else 'DFL')";
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let options = ShellExecuteOptions {
|
||||
command: format!("nohup python3 -c \"{probe}\""),
|
||||
..Default::default()
|
||||
};
|
||||
let result = execute_shell(options, Some(tx), CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
let mut out = String::new();
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
out.push_str(&chunk);
|
||||
}
|
||||
assert!(
|
||||
out.contains("DFL") && !out.contains("IGN"),
|
||||
"builtin nohup masked SIGHUP like the external tool (output: {out:?})",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Adding a provider
|
||||
|
||||
Providers in `packages/ai` are described by a single declarative
|
||||
`ProviderDefinition` and collected in one registry. Every scattered structure —
|
||||
the `KnownProvider` / `OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`,
|
||||
`DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the
|
||||
`/login` provider list, the `refreshOAuthToken` / `AuthStorage.login` dispatch,
|
||||
and the coding-agent callback maps — is **derived** from that registry.
|
||||
|
||||
**Scope.** This is for a provider that reuses an existing wire API
|
||||
(`openai-completions`, `anthropic-messages`, `google-generative-ai`, …) — the
|
||||
common case for gateways and API-key providers, since stream dispatch keys on
|
||||
`model.api`, not `model.provider`. Adding a *new wire protocol* (a new
|
||||
`KnownApi`) is a separate task that also touches `stream.ts` dispatch,
|
||||
`api-registry.ts`, and `types.ts`.
|
||||
|
||||
## Shape
|
||||
|
||||
For the common case, a provider is still **one new def file + one registry line**:
|
||||
|
||||
1. **Create `packages/ai/src/registry/<id>.ts`** exporting one
|
||||
`export const <camelId>Provider = { … } as const satisfies ProviderDefinition;`.
|
||||
2. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts`
|
||||
(one import + one array entry). `ALL` order is the `/login` list order for
|
||||
loginable providers.
|
||||
|
||||
That is the full change for:
|
||||
- env-key-only providers,
|
||||
- providers with a simple inline API-key login flow,
|
||||
- most OpenAI-compatible gateways.
|
||||
|
||||
For a **non-trivial provider-local OAuth flow**, put the implementation in
|
||||
`packages/ai/src/registry/oauth/<vendor>.ts` and lazy-import it from the def
|
||||
file. The shared OAuth flow infrastructure it builds on lives in the same
|
||||
`registry/oauth/` directory.
|
||||
|
||||
Either way, descriptors, default-model map, env-key map, login list, and refresh
|
||||
dispatch all update automatically, and the `KnownProvider` / `OAuthProvider`
|
||||
unions gain the new id by derivation.
|
||||
|
||||
## `ProviderDefinition` fields
|
||||
|
||||
See `packages/ai/src/registry/types.ts` for the authoritative,
|
||||
JSDoc-annotated interface. Presence of a field opts the provider into a derived
|
||||
structure:
|
||||
|
||||
| Field | Effect |
|
||||
|---|---|
|
||||
| `id`, `name` | Required. `name` shows in the `/login` list. |
|
||||
| `defaultModel` | Present ⇒ member of `KnownProvider` (a chat-model provider). |
|
||||
| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. |
|
||||
| `allowUnauthenticated` | Runtime creates a model manager even without a key. |
|
||||
| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. |
|
||||
| `catalogDiscovery` | `{ label, envVars, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). |
|
||||
| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. |
|
||||
| `envKeys` | Env-var fallback for `getEnvApiKey`: a var name string or a `() => string \| undefined` resolver. |
|
||||
| `login` | Interactive login. Present ⇒ member of `OAuthProvider`, shown in `/login`, dispatchable via `AuthStorage.login`. Returns an api-key `string` or `OAuthCredentials`. |
|
||||
| `refreshToken` | OAuth refresher; omit for static-token providers (the dispatch returns credentials unchanged). |
|
||||
| `storeCredentialsAs` | Store credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). |
|
||||
| `callbackPort` | Present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. |
|
||||
| `pasteCodeFlow` | OAuth flow needs a pasted code/redirect URL ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Use `... as const satisfies ProviderDefinition` so the literal `id` is preserved
|
||||
for the union derivation.
|
||||
- `login` / `refreshToken` for simple API-key or validation-based flows can live
|
||||
directly in the provider def file (export the named login function there so
|
||||
tests can import it directly).
|
||||
- `login` / `refreshToken` for heavy provider-local OAuth flows MUST reach the
|
||||
adjacent `registry/oauth/*` module via a dynamic-import
|
||||
thunk (`const { loginX } = await import("./oauth/x"); return loginX(cb);`),
|
||||
keeping those flows out of the eager startup graph.
|
||||
- All OAuth code lives under `registry/oauth/`: the shared flow infra
|
||||
(`callback-server`, `pkce`, `google-oauth-shared`, `types`, the runtime API
|
||||
`index`) plus every provider flow, including the `github-copilot` / `kimi` /
|
||||
`openai-codex` helpers reused by the streaming and usage layers. The non-OAuth
|
||||
API-key helpers (`api-key-login`, `api-key-validation`) sit beside the def
|
||||
files in `registry/`, since they back simple paste-an-API-key logins.
|
||||
- For a simple OpenAI-compatible gateway, build the manager inline with the
|
||||
exported `createSimpleOpenAICompletionsOptions(providerId, baseUrl, config)` —
|
||||
no edits to `openai-compat.ts` required.
|
||||
- A `ProviderDefinition` may also be registered at runtime by an extension via
|
||||
`registerOAuthProvider` (the `AuthStorage.login` dispatcher handles built-ins
|
||||
and extensions through the same path).
|
||||
+2
-2
@@ -27,7 +27,7 @@ Tool params:
|
||||
language: "py" | "js";
|
||||
code: string;
|
||||
title?: string;
|
||||
timeout?: number; // seconds, clamped to 1..600, default 30. Inactivity budget — see "Cell timeout".
|
||||
timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout".
|
||||
reset?: boolean; // reset this cell's selected runtime before execution
|
||||
}>;
|
||||
}
|
||||
@@ -232,7 +232,7 @@ Output is streamed through `OutputSink` and may be persisted to artifact storage
|
||||
|
||||
- **Python backend not available** — Check `eval.py`, `PI_PY`, and that `python`/`python3` is on PATH. If preflight fails and `eval.js` is enabled, use a `js` cell.
|
||||
- **No Python on PATH** — Install a system Python 3.8+ or place a venv at `~/.omp/python-env`. `omp setup python --check` reports the resolved interpreter.
|
||||
- **Execution hangs then times out** — Increase tool `timeout` (max 600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request.
|
||||
- **Execution hangs then times out** — Increase tool `timeout` (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request.
|
||||
- **stdin/input prompts in Python code** — `input()` is not supported; pass data programmatically.
|
||||
- **Working directory errors** — Tool validates `cwd` exists and is a directory before execution.
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface Rule {
|
||||
alwaysApply?: boolean;
|
||||
description?: string;
|
||||
condition?: string[];
|
||||
astCondition?: string[];
|
||||
scope?: string[];
|
||||
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
|
||||
_source: SourceMeta;
|
||||
@@ -183,16 +184,16 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl
|
||||
|
||||
1. Drop rules listed in `ttsr.disabledRules`.
|
||||
2. Drop rules from the `builtin-defaults` provider when `ttsr.builtinRules === false`.
|
||||
3. Register rules with non-empty `condition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only.
|
||||
3. Register rules with a non-empty `condition` or `astCondition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only.
|
||||
4. Put remaining `alwaysApply === true` rules into `alwaysApplyRules`.
|
||||
5. Put remaining rules with `description` into `rulebookRules`.
|
||||
|
||||
### Bucket behavior
|
||||
|
||||
- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets.
|
||||
- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` (regex) or `astCondition` (ast-grep patterns) that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets.
|
||||
- **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`.
|
||||
- **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`.
|
||||
- A rule with both `condition` and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply.
|
||||
- A rule with both a trigger condition and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply.
|
||||
- A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook).
|
||||
|
||||
## 6. How metadata affects runtime surfaces
|
||||
@@ -219,10 +220,11 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl
|
||||
- **Full rule content is auto-injected into the system prompt** (before the rulebook rules section).
|
||||
- Rule is also addressable via `rule://<name>` for re-reading.
|
||||
|
||||
### `condition`, `scope`, and `interruptMode`
|
||||
### `condition`, `astCondition`, `scope`, and `interruptMode`
|
||||
|
||||
- `condition` is the current TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing.
|
||||
- `scope` narrows TTSR matching scope. A condition token that looks like a file glob becomes `tool:edit(<glob>)` and `tool:write(<glob>)` scope entries plus catch-all condition `.*`.
|
||||
- `condition` is the regex TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing.
|
||||
- `astCondition` is the ast-grep trigger field: a string or list of structural patterns, kept verbatim (no glob inference). It only matches on edit/write tool streams, where the language is inferred from the file path. A rule may set `condition`, `astCondition`, or both.
|
||||
- `scope` narrows TTSR matching scope. A `condition` token that looks like a file glob becomes `tool:edit(<glob>)` and `tool:write(<glob>)` scope entries plus catch-all condition `.*`; `astCondition` tokens never trigger this shorthand.
|
||||
- `interruptMode` can override the global TTSR interrupt mode for the rule.
|
||||
|
||||
## 7. System prompt inclusion path
|
||||
@@ -240,18 +242,18 @@ This is advisory/contextual: prompt text asks the model to read applicable rules
|
||||
|
||||
## 8. `rule://` internal URL behavior
|
||||
|
||||
`RuleProtocolHandler` is registered with:
|
||||
`RuleProtocolHandler` resolves against the process-global active-rule snapshot
|
||||
installed once per top-level session in `sdk.ts`:
|
||||
|
||||
```ts
|
||||
new RuleProtocolHandler({
|
||||
getRules: () => [...rulebookRules, ...alwaysApplyRules],
|
||||
});
|
||||
setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]);
|
||||
```
|
||||
|
||||
Implications:
|
||||
|
||||
- `rule://<name>` resolves against both **rulebookRules** and **alwaysApplyRules**.
|
||||
- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`.
|
||||
- `rule://<name>` resolves against **rulebookRules**, **alwaysApplyRules**, and **registered TTSR rules**.
|
||||
- TTSR rules are bucketed out before rulebook/always, but `ttsrManager.getRules()` re-adds them to the snapshot so a triggered rule (e.g. a builtin) stays addressable for re-reading.
|
||||
- Rules with no description, no `alwaysApply`, and no accepted TTSR condition are not addressable via `rule://`.
|
||||
- Resolution is exact name match.
|
||||
- Unknown names return error listing available rule names.
|
||||
- Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`.
|
||||
@@ -260,5 +262,5 @@ Implications:
|
||||
|
||||
1. The rule providers currently loaded for `rules` are `native`, `agents`, `cursor`, `windsurf`, `cline`, and embedded `builtin-defaults`; provider files for other tools may parse other config formats but do not register rule loaders.
|
||||
2. `globs` metadata is surfaced to prompt/UI and is used as a global path gate for TTSR matching, but it is not used to automatically select rulebook rules for `rule://`.
|
||||
3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules.
|
||||
3. Rule selection for `rule://` includes rulebook, always-apply, and registered TTSR rules (so a triggered TTSR rule can be re-read), but not rules that registered no condition and carry neither a description nor `alwaysApply`.
|
||||
4. Discovery warnings (`loadCapability("rules").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path.
|
||||
|
||||
@@ -179,11 +179,15 @@ export default function rmRfBlocker(omp: HookAPI): void {
|
||||
```ts
|
||||
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
|
||||
|
||||
// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc.
|
||||
// Common API-key shapes. Not exhaustive — providers using bespoke formats
|
||||
// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.)
|
||||
// need their own entries.
|
||||
const SECRET_PATTERNS = [
|
||||
/\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
|
||||
/\bAKIA[A-Z0-9]{16}\b/g,
|
||||
/\bghp_[a-zA-Z0-9]{36}\b/g,
|
||||
// Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix).
|
||||
/\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g,
|
||||
/\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
|
||||
];
|
||||
|
||||
|
||||
@@ -43,12 +43,18 @@ const { rulebookRules, alwaysApplyRules } = bucketRules(
|
||||
|
||||
Registration is skipped when:
|
||||
|
||||
- `rule.condition` is absent or all condition regexes fail to compile
|
||||
- both `rule.condition` (regex) and `rule.astCondition` (ast-grep patterns) are absent, or every regex condition fails to compile and there are no AST conditions
|
||||
- a rule with the same `rule.name` was already registered in this manager
|
||||
- the rule scope excludes all monitored streams
|
||||
|
||||
Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines `globs`, those globs are compiled as a global file-path gate for matching.
|
||||
|
||||
### AST conditions (`astCondition`)
|
||||
|
||||
A rule may carry `astCondition`: a list of [ast-grep](https://ast-grep.github.io/) patterns (OR'd, same as regex `condition`), matched structurally instead of textually. A repeated metavariable inside one pattern requires both occurrences to be equal (`if ($X) clearTimeout($X)` matches but `if ($X) clearTimeout($Y)` does not).
|
||||
|
||||
AST conditions only evaluate on **edit/write tool-argument streams** — they need a language, which is inferred from the file extension on the tool's path argument, and they match against the tool's reconstructed source snapshot (`matcherDigest`), not the raw wire delta. Matching is performed in memory by the native `astMatch` engine (no temp files) with Smart strictness. Streams without a usable file path (prose, thinking, path-less tool calls) skip AST conditions entirely. A rule may mix `condition` and `astCondition`; the regex paths keep working on every scope while AST paths apply only to those tool streams.
|
||||
|
||||
### Setting caveat
|
||||
|
||||
`TtsrSettings.enabled` is loaded into the manager but is not currently checked in runtime gating. If TTSR rules exist, matching still runs.
|
||||
@@ -69,9 +75,10 @@ When assistant updates arrive and rules exist:
|
||||
|
||||
- monitor `text_delta`, `thinking_delta`, and `toolcall_delta`
|
||||
- append delta into a source/tool scoped manager buffer
|
||||
- call `checkDelta(delta, matchContext)`
|
||||
- call `checkDelta(delta, matchContext)` (synchronous regex matching)
|
||||
- for edit/write tool streams, when `hasAstRules()` is true, `await checkAstSnapshot(snapshot, matchContext)` (asynchronous AST matching)
|
||||
|
||||
`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, condition, and repeat policy checks.
|
||||
`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, regex condition, and repeat policy checks. `checkAstSnapshot()` applies the same scope/path/repeat gates, then runs each candidate rule's `astCondition` patterns against the snapshot via the native `astMatch` engine. It is throttled per stream key: an identical consecutive snapshot (common when only non-source arguments change between deltas) is skipped without re-running the matcher. Both paths feed their matches through the same trigger-decision handler.
|
||||
|
||||
## 3. Trigger decision and immediate abort path
|
||||
|
||||
|
||||
+9
-9
@@ -20,15 +20,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.10.4",
|
||||
"@oh-my-pi/omp-stats": "15.10.4",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.4",
|
||||
"@oh-my-pi/pi-ai": "15.10.4",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.4",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.4",
|
||||
"@oh-my-pi/pi-natives": "15.10.4",
|
||||
"@oh-my-pi/pi-tui": "15.10.4",
|
||||
"@oh-my-pi/pi-utils": "15.10.4",
|
||||
"@oh-my-pi/hashline": "15.10.5",
|
||||
"@oh-my-pi/omp-stats": "15.10.5",
|
||||
"@oh-my-pi/pi-agent-core": "15.10.5",
|
||||
"@oh-my-pi/pi-ai": "15.10.5",
|
||||
"@oh-my-pi/pi-coding-agent": "15.10.5",
|
||||
"@oh-my-pi/pi-mnemopi": "15.10.5",
|
||||
"@oh-my-pi/pi-natives": "15.10.5",
|
||||
"@oh-my-pi/pi-tui": "15.10.5",
|
||||
"@oh-my-pi/pi-utils": "15.10.5",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.10.5] - 2026-06-08
|
||||
### Removed
|
||||
|
||||
- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly
|
||||
- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies
|
||||
- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves.
|
||||
- Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures.
|
||||
- Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block
|
||||
- Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying
|
||||
- Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors
|
||||
- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results
|
||||
- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay
|
||||
- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls
|
||||
|
||||
## [15.10.3] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
+321
-181
@@ -23,6 +23,7 @@ import {
|
||||
type HarmonyDetection,
|
||||
type HarmonyRecoveredToolCall,
|
||||
isHarmonyLeakMitigationTarget,
|
||||
recoverHarmonyToolCall,
|
||||
signalListLabel,
|
||||
} from "./harmony-leak";
|
||||
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
||||
@@ -68,6 +69,76 @@ class HarmonyLeakInterruption extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type AssistantContentBlock = AssistantMessage["content"][number];
|
||||
type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
|
||||
type CloneableRecord = Record<string, unknown>;
|
||||
|
||||
function cloneUnknown(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(cloneUnknown);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const source = value as CloneableRecord;
|
||||
const out: CloneableRecord = {};
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
out[key] = cloneUnknown(child);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
|
||||
return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
|
||||
}
|
||||
|
||||
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return { ...block };
|
||||
case "thinking":
|
||||
return { ...block };
|
||||
case "redactedThinking":
|
||||
return { ...block };
|
||||
case "toolCall":
|
||||
return { ...block, arguments: cloneToolArguments(block.arguments) };
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map(snapshotAssistantContentBlock),
|
||||
usage: {
|
||||
...message.usage,
|
||||
cost: { ...message.usage.cost },
|
||||
},
|
||||
disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
return { ...event, partial: snapshotAssistantMessage(event.partial) };
|
||||
case "text_start":
|
||||
case "text_delta":
|
||||
case "text_end":
|
||||
case "thinking_start":
|
||||
case "thinking_delta":
|
||||
case "thinking_end":
|
||||
case "toolcall_start":
|
||||
case "toolcall_delta":
|
||||
return { ...event, partial: snapshotAssistantMessage(event.partial) };
|
||||
case "toolcall_end":
|
||||
return {
|
||||
...event,
|
||||
toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
|
||||
partial: snapshotAssistantMessage(event.partial),
|
||||
};
|
||||
case "done":
|
||||
return { ...event, message: snapshotAssistantMessage(event.message) };
|
||||
case "error":
|
||||
return { ...event, error: snapshotAssistantMessage(event.error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
|
||||
* into a structurally valid {@link AgentToolResult}.
|
||||
@@ -77,7 +148,7 @@ class HarmonyLeakInterruption extends Error {
|
||||
* (missing `content` array → crash on reload). We coerce at the single boundary where untyped
|
||||
* results enter the agent loop, so every downstream consumer can rely on the type.
|
||||
*/
|
||||
function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malformed: boolean } {
|
||||
function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
|
||||
const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const rawContent = rawObj?.content;
|
||||
const details = rawObj && "details" in rawObj ? rawObj.details : {};
|
||||
@@ -98,8 +169,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
|
||||
}
|
||||
|
||||
const content: AgentToolResult["content"] = [];
|
||||
let invalidBlocks = 0;
|
||||
for (const block of rawContent) {
|
||||
if (!block || typeof block !== "object" || !("type" in block)) continue;
|
||||
if (!block || typeof block !== "object" || !("type" in block)) {
|
||||
invalidBlocks++;
|
||||
continue;
|
||||
}
|
||||
if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
|
||||
content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
|
||||
} else if (
|
||||
@@ -108,9 +183,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
|
||||
typeof (block as { mimeType?: unknown }).mimeType === "string"
|
||||
) {
|
||||
content.push(block as { type: "image"; data: string; mimeType: string });
|
||||
} else {
|
||||
invalidBlocks++;
|
||||
}
|
||||
}
|
||||
return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false };
|
||||
if (invalidBlocks > 0) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) },
|
||||
malformed: invalidBlocks > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +262,7 @@ export function agentLoopContinue(
|
||||
|
||||
(async () => {
|
||||
const newMessages: AgentMessage[] = [];
|
||||
const currentContext: AgentContext = { ...context };
|
||||
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
|
||||
|
||||
stream.push({ type: "agent_start" });
|
||||
stream.push({ type: "turn_start" });
|
||||
@@ -313,22 +399,26 @@ function normalizeMessagesForProvider(
|
||||
return messages;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const normalized = messages.map(message => {
|
||||
let hasThinking = false;
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
||||
for (const block of message.content) {
|
||||
if (block.type === "thinking") {
|
||||
hasThinking = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasThinking) break;
|
||||
}
|
||||
if (!hasThinking) return messages;
|
||||
|
||||
return messages.map(message => {
|
||||
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const filtered = message.content.filter(block => block.type !== "thinking");
|
||||
if (filtered.length === message.content.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return { ...message, content: filtered };
|
||||
return filtered.length === message.content.length ? message : { ...message, content: filtered };
|
||||
});
|
||||
|
||||
return changed ? normalized : messages;
|
||||
}
|
||||
|
||||
export const INTENT_FIELD = "_i";
|
||||
@@ -445,27 +535,6 @@ interface StepCounter {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined {
|
||||
if (value === undefined || !Number.isFinite(value)) return undefined;
|
||||
const normalized = Math.trunc(value);
|
||||
return normalized > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage {
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map(block => {
|
||||
if (block.type === "toolCall") {
|
||||
return { ...block, arguments: structuredClone(block.arguments) };
|
||||
}
|
||||
return { ...block };
|
||||
}),
|
||||
stopReason: "toolUse",
|
||||
errorMessage: undefined,
|
||||
errorStatus: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
||||
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
||||
@@ -573,6 +642,12 @@ async function runLoopBody(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (recovered) {
|
||||
message = snapshotAssistantMessage(message);
|
||||
currentContext.messages.push(message);
|
||||
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
|
||||
stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
|
||||
}
|
||||
newMessages.push(message);
|
||||
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
||||
|
||||
@@ -661,13 +736,24 @@ async function runLoopBody(
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
if (message.stopReason === "length" && toolResults.length > 0) {
|
||||
hasMoreToolCalls = true;
|
||||
}
|
||||
}
|
||||
|
||||
stream.push({ type: "turn_end", message, toolResults });
|
||||
|
||||
const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
||||
const asides = resolveAsides(await config.getAsideMessages?.());
|
||||
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
||||
if (hasMoreToolCalls) {
|
||||
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
||||
const asides = resolveAsides(await config.getAsideMessages?.());
|
||||
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
||||
} else {
|
||||
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
||||
// asides for the outer drain below so a passive aside can't trigger an extra model
|
||||
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
||||
pendingMessages = steering;
|
||||
}
|
||||
}
|
||||
|
||||
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
|
||||
@@ -761,18 +847,11 @@ async function streamAssistantResponse(
|
||||
const dynamicReasoning = config.getReasoning?.();
|
||||
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
|
||||
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
||||
const maxToolCallsPerTurn = normalizeMaxToolCallsPerTurn(config.maxToolCallsPerTurn);
|
||||
const toolCallCapAbortController = maxToolCallsPerTurn === undefined ? undefined : new AbortController();
|
||||
const requestSignals: AbortSignal[] = [];
|
||||
if (signal) requestSignals.push(signal);
|
||||
if (harmonyAbortController) requestSignals.push(harmonyAbortController.signal);
|
||||
if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal);
|
||||
const requestSignal =
|
||||
requestSignals.length === 0
|
||||
? undefined
|
||||
: requestSignals.length === 1
|
||||
? requestSignals[0]
|
||||
: AbortSignal.any(requestSignals);
|
||||
const requestSignal = harmonyAbortController
|
||||
? signal
|
||||
? AbortSignal.any([signal, harmonyAbortController.signal])
|
||||
: harmonyAbortController.signal
|
||||
: signal;
|
||||
const effectiveTemperature =
|
||||
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
|
||||
const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
|
||||
@@ -844,27 +923,27 @@ async function streamAssistantResponse(
|
||||
|
||||
let partialMessage: AssistantMessage | null = null;
|
||||
let addedPartial = false;
|
||||
const completedToolCallIds = new Set<string>();
|
||||
|
||||
const responseIterator = response[Symbol.asyncIterator]();
|
||||
let completedToolCalls = 0;
|
||||
let cappedMessage: AssistantMessage | undefined;
|
||||
let capFinalized = false;
|
||||
|
||||
const finishCappedAssistantMessage = async (): Promise<AssistantMessage | undefined> => {
|
||||
if (!cappedMessage) return undefined;
|
||||
responseIterator.return?.()?.catch(() => {});
|
||||
if (!capFinalized) {
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = cappedMessage;
|
||||
} else {
|
||||
context.messages.push(cappedMessage);
|
||||
stream.push({ type: "message_start", message: { ...cappedMessage } });
|
||||
}
|
||||
stream.push({ type: "message_end", message: cappedMessage });
|
||||
await finishChat(cappedMessage);
|
||||
capFinalized = true;
|
||||
const finishAbortedStream = async (): Promise<AssistantMessage> => {
|
||||
try {
|
||||
const cleanup = responseIterator.return?.();
|
||||
if (cleanup) void cleanup.catch(() => {});
|
||||
} catch {
|
||||
// Provider cancellation failures cannot change the committed aborted message.
|
||||
}
|
||||
return cappedMessage;
|
||||
const aborted = emitAbortedAssistantMessage(
|
||||
partialMessage,
|
||||
addedPartial,
|
||||
completedToolCallIds,
|
||||
context,
|
||||
config,
|
||||
stream,
|
||||
requestSignal,
|
||||
);
|
||||
await finishChat(aborted);
|
||||
return aborted;
|
||||
};
|
||||
|
||||
// Set up a single abort race: register the abort listener once for the whole
|
||||
@@ -874,16 +953,7 @@ async function streamAssistantResponse(
|
||||
let detachAbortListener: (() => void) | undefined;
|
||||
if (requestSignal) {
|
||||
if (requestSignal.aborted) {
|
||||
const aborted = emitAbortedAssistantMessage(
|
||||
partialMessage,
|
||||
addedPartial,
|
||||
context,
|
||||
config,
|
||||
stream,
|
||||
requestSignal,
|
||||
);
|
||||
await finishChat(aborted);
|
||||
return aborted;
|
||||
return await finishAbortedStream();
|
||||
}
|
||||
const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
|
||||
const onAbort = () => resolve(ABORTED);
|
||||
@@ -898,45 +968,51 @@ async function streamAssistantResponse(
|
||||
if (abortRacePromise) {
|
||||
const result = await Promise.race([responseIterator.next(), abortRacePromise]);
|
||||
if (result === ABORTED) {
|
||||
if (toolCallCapAbortController?.signal.aborted) {
|
||||
const capped = await finishCappedAssistantMessage();
|
||||
if (capped) return capped;
|
||||
}
|
||||
responseIterator.return?.()?.catch(() => {});
|
||||
const aborted = emitAbortedAssistantMessage(
|
||||
partialMessage,
|
||||
addedPartial,
|
||||
context,
|
||||
config,
|
||||
stream,
|
||||
requestSignal,
|
||||
);
|
||||
await finishChat(aborted);
|
||||
return aborted;
|
||||
return await finishAbortedStream();
|
||||
}
|
||||
next = result;
|
||||
} else {
|
||||
next = await responseIterator.next();
|
||||
}
|
||||
if (requestSignal?.aborted) {
|
||||
if (toolCallCapAbortController?.signal.aborted) {
|
||||
const capped = await finishCappedAssistantMessage();
|
||||
if (capped) return capped;
|
||||
}
|
||||
const aborted = emitAbortedAssistantMessage(
|
||||
partialMessage,
|
||||
addedPartial,
|
||||
context,
|
||||
config,
|
||||
stream,
|
||||
requestSignal,
|
||||
);
|
||||
await finishChat(aborted);
|
||||
return aborted;
|
||||
}
|
||||
if (next.done) break;
|
||||
|
||||
const event = next.value;
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds);
|
||||
if (harmonyMitigationEnabled) {
|
||||
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
|
||||
if (detection) {
|
||||
const recovered = recoverHarmonyToolCall(finalMessage, detection);
|
||||
const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
|
||||
if (addedPartial) {
|
||||
emitDiscardedHarmonyPartial(
|
||||
partialMessage,
|
||||
stream,
|
||||
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
||||
);
|
||||
context.messages.pop();
|
||||
addedPartial = false;
|
||||
}
|
||||
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
||||
}
|
||||
}
|
||||
finalMessage = snapshotAssistantMessage(finalMessage);
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = finalMessage;
|
||||
} else {
|
||||
context.messages.push(finalMessage);
|
||||
}
|
||||
if (!addedPartial) {
|
||||
stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
|
||||
}
|
||||
stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
|
||||
await finishChat(finalMessage);
|
||||
return finalMessage;
|
||||
}
|
||||
if (requestSignal?.aborted) {
|
||||
return await finishAbortedStream();
|
||||
}
|
||||
|
||||
// Yield to the event loop periodically to prevent busy-wait
|
||||
// when the LLM is streaming chunks faster than the loop can rest.
|
||||
await yieldIfDue();
|
||||
@@ -946,7 +1022,7 @@ async function streamAssistantResponse(
|
||||
partialMessage = event.partial;
|
||||
context.messages.push(partialMessage);
|
||||
addedPartial = true;
|
||||
stream.push({ type: "message_start", message: { ...partialMessage } });
|
||||
stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
|
||||
break;
|
||||
|
||||
case "text_start":
|
||||
@@ -959,72 +1035,48 @@ async function streamAssistantResponse(
|
||||
case "toolcall_delta":
|
||||
case "toolcall_end":
|
||||
if (partialMessage) {
|
||||
if (event.type === "toolcall_end") {
|
||||
completedToolCallIds.add(event.toolCall.id);
|
||||
}
|
||||
partialMessage = event.partial;
|
||||
context.messages[context.messages.length - 1] = partialMessage;
|
||||
config.onAssistantMessageEvent?.(partialMessage, event);
|
||||
if (signal?.aborted) {
|
||||
continue;
|
||||
}
|
||||
stream.push({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: event,
|
||||
message: { ...partialMessage },
|
||||
assistantMessageEvent: snapshotAssistantMessageEvent(event),
|
||||
message: snapshotAssistantMessage(partialMessage),
|
||||
});
|
||||
if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) {
|
||||
completedToolCalls++;
|
||||
if (completedToolCalls >= maxToolCallsPerTurn) {
|
||||
cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage);
|
||||
toolCallCapAbortController?.abort();
|
||||
const capped = await finishCappedAssistantMessage();
|
||||
if (capped) return capped;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "done":
|
||||
case "error": {
|
||||
const finalMessage = await response.result();
|
||||
if (harmonyMitigationEnabled) {
|
||||
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
|
||||
if (detection) {
|
||||
const removed = extractHarmonyRemoved(finalMessage, detection);
|
||||
if (addedPartial) {
|
||||
context.messages.pop();
|
||||
addedPartial = false;
|
||||
}
|
||||
throw new HarmonyLeakInterruption(detection, removed);
|
||||
}
|
||||
}
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = finalMessage;
|
||||
} else {
|
||||
context.messages.push(finalMessage);
|
||||
}
|
||||
if (!addedPartial) {
|
||||
stream.push({ type: "message_start", message: { ...finalMessage } });
|
||||
}
|
||||
stream.push({ type: "message_end", message: finalMessage });
|
||||
await finishChat(finalMessage);
|
||||
return finalMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
detachAbortListener?.();
|
||||
}
|
||||
|
||||
const trailing = await response.result();
|
||||
let trailing = await response.result();
|
||||
if (harmonyMitigationEnabled) {
|
||||
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
||||
if (detection) {
|
||||
const recovered = recoverHarmonyToolCall(trailing, detection);
|
||||
const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
|
||||
if (addedPartial) {
|
||||
emitDiscardedHarmonyPartial(
|
||||
partialMessage,
|
||||
stream,
|
||||
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
||||
);
|
||||
context.messages.pop();
|
||||
addedPartial = false;
|
||||
}
|
||||
throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection));
|
||||
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
||||
}
|
||||
}
|
||||
trailing = snapshotAssistantMessage(trailing);
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = trailing;
|
||||
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
||||
}
|
||||
await finishChat(trailing);
|
||||
return trailing;
|
||||
});
|
||||
@@ -1038,6 +1090,33 @@ async function streamAssistantResponse(
|
||||
}
|
||||
}
|
||||
|
||||
function retainCompletedToolCalls(
|
||||
message: AssistantMessage,
|
||||
completedToolCallIds: ReadonlySet<string>,
|
||||
): AssistantMessage {
|
||||
if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
|
||||
let changed = false;
|
||||
const content = message.content.filter(block => {
|
||||
if (block.type !== "toolCall") return true;
|
||||
const keep = completedToolCallIds.has(block.id);
|
||||
if (!keep) changed = true;
|
||||
return keep;
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
}
|
||||
|
||||
function emitDiscardedHarmonyPartial(
|
||||
partialMessage: AssistantMessage | null,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
errorMessage: string,
|
||||
): void {
|
||||
if (!partialMessage) return;
|
||||
stream.push({
|
||||
type: "message_end",
|
||||
message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the human-readable reason an abort carried. A caller that aborts via
|
||||
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
|
||||
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
|
||||
@@ -1053,16 +1132,31 @@ export function abortReasonText(signal: AbortSignal | undefined): string {
|
||||
return "Request was aborted";
|
||||
}
|
||||
|
||||
/** True when an abort carried a *deliberate*, human-meaningful reason — a string
|
||||
* reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label).
|
||||
* A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns
|
||||
* false. Used to decide whether a mid-stream tool call survives the abort: a
|
||||
* deliberate interruption is a conscious decision made after the (partial) call
|
||||
* was observed, so the block is retained and paired with a labeled placeholder;
|
||||
* an anonymous abort drops incomplete calls whose args may be unsafe to replay. */
|
||||
function isExplicitAbortReason(signal: AbortSignal | undefined): boolean {
|
||||
const reason = signal?.reason;
|
||||
if (typeof reason === "string") return reason.trim().length > 0;
|
||||
if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function emitAbortedAssistantMessage(
|
||||
partialMessage: AssistantMessage | null,
|
||||
addedPartial: boolean,
|
||||
completedToolCallIds: ReadonlySet<string>,
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
requestSignal: AbortSignal | undefined,
|
||||
): AssistantMessage {
|
||||
const errorMessage = abortReasonText(requestSignal);
|
||||
const abortedMessage: AssistantMessage = partialMessage
|
||||
const base: AssistantMessage = partialMessage
|
||||
? { ...partialMessage, stopReason: "aborted", errorMessage }
|
||||
: {
|
||||
role: "assistant",
|
||||
@@ -1082,13 +1176,19 @@ function emitAbortedAssistantMessage(
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every
|
||||
// committed tool-call block so the loop pairs it with a placeholder labeled by
|
||||
// `errorMessage`; an anonymous abort still drops calls that never completed
|
||||
// (no `toolcall_end`), whose partial args are unsafe to replay.
|
||||
const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds);
|
||||
const abortedMessage = snapshotAssistantMessage(retained);
|
||||
if (addedPartial) {
|
||||
context.messages[context.messages.length - 1] = abortedMessage;
|
||||
} else {
|
||||
context.messages.push(abortedMessage);
|
||||
stream.push({ type: "message_start", message: { ...abortedMessage } });
|
||||
stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
|
||||
}
|
||||
stream.push({ type: "message_end", message: abortedMessage });
|
||||
stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
|
||||
return abortedMessage;
|
||||
}
|
||||
|
||||
@@ -1126,7 +1226,7 @@ async function executeToolCalls(
|
||||
: steeringAbortController.signal;
|
||||
const interruptState = { triggered: false };
|
||||
let steeringMessages: AgentMessage[] | undefined;
|
||||
let steeringCheck: Promise<void> | null = null;
|
||||
let steeringCheckTail: Promise<void> = Promise.resolve();
|
||||
|
||||
const records = toolCalls.map(toolCall => ({
|
||||
toolCall,
|
||||
@@ -1150,21 +1250,17 @@ async function executeToolCalls(
|
||||
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
|
||||
return;
|
||||
}
|
||||
if (steeringCheck) {
|
||||
await steeringCheck;
|
||||
return;
|
||||
}
|
||||
steeringCheck = (async () => {
|
||||
const check = steeringCheckTail.then(async () => {
|
||||
if (interruptState.triggered) return;
|
||||
const steering = await getSteeringMessages();
|
||||
if (steering.length > 0) {
|
||||
steeringMessages = steering;
|
||||
interruptState.triggered = true;
|
||||
steeringAbortController.abort();
|
||||
}
|
||||
})().finally(() => {
|
||||
steeringCheck = null;
|
||||
});
|
||||
await steeringCheck;
|
||||
steeringCheckTail = check.catch(() => {});
|
||||
await check;
|
||||
};
|
||||
|
||||
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
||||
@@ -1236,6 +1332,16 @@ async function executeToolCalls(
|
||||
}
|
||||
}
|
||||
record.args = argsForExecution;
|
||||
if (toolSignal.aborted) {
|
||||
record.skipped = true;
|
||||
recordSkippedTool(telemetry, {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
status: "aborted",
|
||||
});
|
||||
emitToolResult(record, createToolSignalAbortedResult(toolSignal), true);
|
||||
return;
|
||||
}
|
||||
record.started = true;
|
||||
stream.push({
|
||||
type: "tool_execution_start",
|
||||
@@ -1259,10 +1365,16 @@ async function executeToolCalls(
|
||||
let result: AgentToolResult<any> = { content: [], details: {} };
|
||||
let isError = false;
|
||||
let caughtError: unknown;
|
||||
let completedToolExecution = false;
|
||||
|
||||
await runInActiveSpan(toolSpan, async () => {
|
||||
try {
|
||||
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
|
||||
if (toolSignal.aborted) {
|
||||
result = createToolSignalAbortedResult(toolSignal);
|
||||
isError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let effectiveArgs: Record<string, unknown>;
|
||||
try {
|
||||
@@ -1289,8 +1401,15 @@ async function executeToolCalls(
|
||||
throw new ToolCallBlockedError(beforeResult.reason);
|
||||
}
|
||||
}
|
||||
// Reflect post-hook args so emitted tool results / afterToolCall see what actually executed.
|
||||
record.args = effectiveArgs;
|
||||
if (toolSignal.aborted) {
|
||||
result = createToolSignalAbortedResult(toolSignal);
|
||||
isError = true;
|
||||
return;
|
||||
}
|
||||
const executionArgs = transformToolCallArguments
|
||||
? transformToolCallArguments(effectiveArgs, toolCall.name)
|
||||
: effectiveArgs;
|
||||
record.args = executionArgs;
|
||||
|
||||
const toolContext = getToolContext
|
||||
? getToolContext({
|
||||
@@ -1302,19 +1421,20 @@ async function executeToolCalls(
|
||||
: undefined;
|
||||
const rawResult = await tool.execute(
|
||||
toolCall.id,
|
||||
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
|
||||
executionArgs,
|
||||
toolSignal,
|
||||
partialResult => {
|
||||
stream.push({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
args: effectiveArgs,
|
||||
args: executionArgs,
|
||||
partialResult: coerceToolResult(partialResult).result,
|
||||
});
|
||||
},
|
||||
toolContext,
|
||||
);
|
||||
completedToolExecution = true;
|
||||
const coerced = coerceToolResult(rawResult);
|
||||
result = coerced.result;
|
||||
if (coerced.malformed || result.isError) isError = true;
|
||||
@@ -1327,7 +1447,7 @@ async function executeToolCalls(
|
||||
isError = true;
|
||||
}
|
||||
|
||||
if (afterToolCall) {
|
||||
if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) {
|
||||
try {
|
||||
const after = await afterToolCall(
|
||||
{
|
||||
@@ -1341,12 +1461,17 @@ async function executeToolCalls(
|
||||
toolSignal,
|
||||
);
|
||||
if (after) {
|
||||
result = {
|
||||
// Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
|
||||
// code and may return malformed `content` (non-array / invalid blocks), which
|
||||
// would otherwise be persisted verbatim and corrupt the session — the same
|
||||
// hazard `coerceToolResult` guards on the execute path.
|
||||
const coerced = coerceToolResult({
|
||||
content: after.content ?? result.content,
|
||||
details: after.details ?? result.details,
|
||||
isError: after.isError ?? result.isError,
|
||||
};
|
||||
isError = after.isError ?? isError;
|
||||
});
|
||||
result = coerced.result;
|
||||
isError = coerced.malformed || (after.isError ?? isError);
|
||||
}
|
||||
} catch (e) {
|
||||
caughtError = e;
|
||||
@@ -1360,23 +1485,30 @@ async function executeToolCalls(
|
||||
});
|
||||
|
||||
const interrupted = interruptState.triggered;
|
||||
if (interrupted) {
|
||||
const abortedDuringExecution = toolSignal.aborted && isError;
|
||||
if (interrupted && isError) {
|
||||
// Steering/abort fired AND this tool failed — it was cut off before producing a
|
||||
// usable result, so report it as skipped.
|
||||
record.skipped = true;
|
||||
emitToolResult(record, createSkippedToolResult(), true);
|
||||
} else {
|
||||
// No interrupt, or the tool finished (successfully or with a genuine error) before
|
||||
// the interrupt landed. Keep its real result: a completed tool already ran its side
|
||||
// effects, so the model must see what actually happened rather than a false "skipped".
|
||||
emitToolResult(record, result, isError);
|
||||
}
|
||||
|
||||
const firstTextBlock = result.content?.[0];
|
||||
const errorMessageForSpan =
|
||||
caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
|
||||
const status = interrupted
|
||||
? "aborted"
|
||||
: caughtError instanceof ToolCallBlockedError
|
||||
? "blocked"
|
||||
: isError
|
||||
? "error"
|
||||
: "ok";
|
||||
const status =
|
||||
(interrupted && isError) || abortedDuringExecution
|
||||
? "aborted"
|
||||
: caughtError instanceof ToolCallBlockedError
|
||||
? "blocked"
|
||||
: isError
|
||||
? "error"
|
||||
: "ok";
|
||||
finishExecuteToolSpan(telemetry, toolSpan, {
|
||||
result,
|
||||
isError,
|
||||
@@ -1482,6 +1614,14 @@ function createAbortedToolResult(
|
||||
return toolResultMessage;
|
||||
}
|
||||
|
||||
function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
|
||||
const reason = abortReasonText(signal);
|
||||
return {
|
||||
content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createSkippedToolResult(): AgentToolResult<any> {
|
||||
return {
|
||||
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
||||
|
||||
@@ -110,12 +110,6 @@ export interface AgentOptions {
|
||||
*/
|
||||
interruptMode?: "immediate" | "wait";
|
||||
|
||||
/**
|
||||
* Maximum completed tool calls to accept from one streamed assistant turn before
|
||||
* executing the batch. Undefined disables batching.
|
||||
*/
|
||||
maxToolCallsPerTurn?: number;
|
||||
|
||||
/**
|
||||
* API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
|
||||
*/
|
||||
@@ -288,7 +282,6 @@ export class Agent {
|
||||
#steeringMode: "all" | "one-at-a-time";
|
||||
#followUpMode: "all" | "one-at-a-time";
|
||||
#interruptMode: "immediate" | "wait";
|
||||
#maxToolCallsPerTurn?: number;
|
||||
#sessionId?: string;
|
||||
#promptCacheKey?: string;
|
||||
#metadata?: Record<string, unknown>;
|
||||
@@ -350,7 +343,6 @@ export class Agent {
|
||||
this.#steeringMode = opts.steeringMode || "one-at-a-time";
|
||||
this.#followUpMode = opts.followUpMode || "one-at-a-time";
|
||||
this.#interruptMode = opts.interruptMode || "immediate";
|
||||
this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn;
|
||||
this.streamFn = opts.streamFn || streamSimple;
|
||||
this.#sessionId = opts.sessionId;
|
||||
this.#promptCacheKey = opts.promptCacheKey;
|
||||
@@ -588,14 +580,6 @@ export class Agent {
|
||||
this.#maxRetryDelayMs = value;
|
||||
}
|
||||
|
||||
get maxToolCallsPerTurn(): number | undefined {
|
||||
return this.#maxToolCallsPerTurn;
|
||||
}
|
||||
|
||||
set maxToolCallsPerTurn(value: number | undefined) {
|
||||
this.#maxToolCallsPerTurn = value;
|
||||
}
|
||||
|
||||
get state(): AgentState {
|
||||
return this.#state;
|
||||
}
|
||||
@@ -967,7 +951,6 @@ export class Agent {
|
||||
serviceTier: this.#serviceTier,
|
||||
hideThinkingSummary: this.#hideThinkingSummary,
|
||||
interruptMode: this.#interruptMode,
|
||||
maxToolCallsPerTurn: this.#maxToolCallsPerTurn,
|
||||
sessionId: this.#sessionId,
|
||||
promptCacheKey: this.#promptCacheKey,
|
||||
metadata: this.#metadataResolver ? undefined : this.#metadata,
|
||||
|
||||
@@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan(
|
||||
|
||||
/**
|
||||
* Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
|
||||
are caught and logged via `console.warn` — telemetry callbacks NEVER turn a
|
||||
* are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
|
||||
* when no hook is set) — telemetry callbacks NEVER turn a
|
||||
* successful agent run into a failed one. Idempotent at the call site via
|
||||
* {@link AgentRunCollector.markRunEnded}; callers must check that before
|
||||
* calling this helper.
|
||||
|
||||
@@ -47,14 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
*/
|
||||
interruptMode?: "immediate" | "wait";
|
||||
|
||||
/**
|
||||
* Maximum completed tool calls to accept from one streamed assistant turn before
|
||||
* cutting the provider stream and executing that batch. The cap is enforced on
|
||||
* `toolcall_end` so every executed call has complete arguments. Undefined disables
|
||||
* batching.
|
||||
*/
|
||||
maxToolCallsPerTurn?: number;
|
||||
|
||||
/**
|
||||
* Optional session identifier forwarded to LLM providers.
|
||||
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { agentLoop, agentLoopContinue, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop";
|
||||
import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
@@ -7,10 +7,9 @@ import type {
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolContext,
|
||||
StreamFn,
|
||||
ToolCallContext,
|
||||
} from "@oh-my-pi/pi-agent-core/types";
|
||||
import type { AssistantMessage, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import * as z from "zod/v4";
|
||||
@@ -56,6 +55,24 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(eventTypes).toContain("agent_end");
|
||||
});
|
||||
|
||||
it("returns detailed telemetry when awaiting detailed() directly", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: ["You are helpful."],
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] });
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
|
||||
const { detailed } = agentLoopDetailed([createUserMessage("Hello")], context, config, undefined, mock.stream);
|
||||
const result = await detailed();
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.telemetry?.stepCount).toBe(1);
|
||||
expect(result.telemetry?.chats.total).toBe(1);
|
||||
expect(result.coverage?.modelsUsed).toEqual([mock.model.id]);
|
||||
});
|
||||
|
||||
it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: ["You are helpful."],
|
||||
@@ -133,7 +150,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
tools: [],
|
||||
};
|
||||
const mock = createMockModel();
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
const controller = new AbortController();
|
||||
// The mock provider would reject without a configured response; we want the
|
||||
// agent's abort path to kick in before any event is emitted. Use a raw stream
|
||||
@@ -157,6 +174,40 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(events.map(event => event.type)).toContain("agent_end");
|
||||
});
|
||||
|
||||
it("does not wait for provider iterator cleanup when aborting a stalled response", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: ["You are helpful."],
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
const mock = createMockModel();
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
const controller = new AbortController();
|
||||
let returnCalled = false;
|
||||
const streamFn = () =>
|
||||
({
|
||||
result: () => Promise.withResolvers<AssistantMessage>().promise,
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise,
|
||||
return: () => {
|
||||
returnCalled = true;
|
||||
return Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise;
|
||||
},
|
||||
}),
|
||||
}) as AssistantMessageEventStream;
|
||||
|
||||
const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn);
|
||||
queueMicrotask(() => controller.abort("stop now"));
|
||||
const messages = await stream.result();
|
||||
|
||||
expect(returnCalled).toBe(true);
|
||||
const finalMessage = messages[messages.length - 1];
|
||||
expect(finalMessage.role).toBe("assistant");
|
||||
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(finalMessage.stopReason).toBe("aborted");
|
||||
expect(finalMessage.errorMessage).toBe("stop now");
|
||||
});
|
||||
|
||||
it("surfaces a custom abort reason on the synthesized aborted message", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: ["You are helpful."],
|
||||
@@ -164,7 +215,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
tools: [],
|
||||
};
|
||||
const mock = createMockModel();
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
const controller = new AbortController();
|
||||
const streamFn = () => new AssistantMessageEventStream();
|
||||
|
||||
@@ -373,118 +424,6 @@ describe("agentLoop with AgentMessage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("cuts a streamed assistant turn after the configured completed tool-call batch", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `echoed: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
|
||||
const mock = createMockModel();
|
||||
let modelCalls = 0;
|
||||
let firstRequestSignal: AbortSignal | undefined;
|
||||
|
||||
const makeToolCall = (index: number): AssistantMessage["content"][number] => ({
|
||||
type: "toolCall",
|
||||
id: `tool-${index}`,
|
||||
name: "echo",
|
||||
arguments: { value: String(index) },
|
||||
});
|
||||
const makeMessage = (count: number, stopReason: AssistantMessage["stopReason"] = "stop") =>
|
||||
createAssistantMessage(
|
||||
Array.from({ length: count }, (_, index) => makeToolCall(index + 1)),
|
||||
stopReason,
|
||||
);
|
||||
|
||||
const streamFn: StreamFn = (_model, _llmContext, options) => {
|
||||
modelCalls++;
|
||||
const stream = new AssistantMessageEventStream();
|
||||
if (modelCalls > 1) {
|
||||
queueMicrotask(() => {
|
||||
const done = createAssistantMessage([{ type: "text", text: "done" }], "stop");
|
||||
stream.push({ type: "start", partial: done });
|
||||
stream.push({ type: "text_start", contentIndex: 0, partial: done });
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done });
|
||||
stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done });
|
||||
stream.push({ type: "done", reason: "stop", message: done });
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
queueMicrotask(async () => {
|
||||
firstRequestSignal = options?.signal;
|
||||
stream.push({ type: "start", partial: makeMessage(0) });
|
||||
for (let index = 1; index <= 10; index++) {
|
||||
if (options?.signal?.aborted) {
|
||||
const aborted = createAssistantMessage([], "aborted");
|
||||
stream.push({ type: "error", reason: "aborted", error: aborted });
|
||||
return;
|
||||
}
|
||||
const partial = makeMessage(index);
|
||||
const toolCall = partial.content[index - 1];
|
||||
if (toolCall?.type !== "toolCall") throw new Error("Expected tool call");
|
||||
stream.push({ type: "toolcall_start", contentIndex: index - 1, partial });
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: index - 1,
|
||||
delta: JSON.stringify(toolCall.arguments),
|
||||
partial,
|
||||
});
|
||||
stream.push({ type: "toolcall_end", contentIndex: index - 1, toolCall, partial });
|
||||
await Bun.sleep(0);
|
||||
}
|
||||
stream.push({ type: "done", reason: "toolUse", message: makeMessage(10, "toolUse") });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
maxToolCallsPerTurn: 8,
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([createUserMessage("echo many")], context, config, undefined, streamFn);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(executed).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]);
|
||||
expect(firstRequestSignal?.aborted).toBe(true);
|
||||
expect(modelCalls).toBe(2);
|
||||
|
||||
const batchedTurn = events.find(
|
||||
(event): event is Extract<AgentEvent, { type: "turn_end" }> =>
|
||||
event.type === "turn_end" && event.toolResults.length === 8,
|
||||
);
|
||||
expect(batchedTurn).toBeDefined();
|
||||
if (batchedTurn?.message.role !== "assistant") return;
|
||||
expect(batchedTurn.message.stopReason).toBe("toolUse");
|
||||
expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(8);
|
||||
expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([
|
||||
"tool-1",
|
||||
"tool-2",
|
||||
"tool-3",
|
||||
"tool-4",
|
||||
"tool-5",
|
||||
"tool-6",
|
||||
"tool-7",
|
||||
"tool-8",
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects and strips intent when intent tracing is enabled", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
const executedParams: Record<string, unknown>[] = [];
|
||||
@@ -630,7 +569,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(turnEndEvent.toolResults.map(result => result.toolCallId)).toEqual(["tool-2", "tool-1"]);
|
||||
});
|
||||
|
||||
it("emits an explicit warning toolResult when assistant aborts after issuing tool calls", async () => {
|
||||
it("drops incomplete tool calls when assistant aborts before toolcall_end", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: ["You are helpful."],
|
||||
messages: [],
|
||||
@@ -641,8 +580,10 @@ describe("agentLoop with AgentMessage", () => {
|
||||
const mock = createMockModel();
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
|
||||
// Custom stream: emit a partial start with a tool call, abort, then push done.
|
||||
// The mock provider doesn't model "abort between start and done"; do it inline.
|
||||
// Custom stream: emit a partial assistant that already contains a tool
|
||||
// call, then abort before any `toolcall_end` event proves that the args
|
||||
// completed. The agent must not synthesize a toolResult for that partial
|
||||
// call; replaying it would preserve unsafe/incomplete arguments.
|
||||
const streamFn = () => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -669,16 +610,16 @@ describe("agentLoop with AgentMessage", () => {
|
||||
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
|
||||
e.type === "message_end" && e.message.role === "toolResult",
|
||||
);
|
||||
expect(toolResultEvent).toBeDefined();
|
||||
if (toolResultEvent?.message.role !== "toolResult") return;
|
||||
expect(toolResultEvent.message.isError).toBe(true);
|
||||
expect(toolResultEvent.message.toolCallId).toBe("tool-1");
|
||||
expect(toolResultEvent.message.content[0]?.type).toBe("text");
|
||||
if (toolResultEvent.message.content[0]?.type === "text") {
|
||||
const text = toolResultEvent.message.content[0].text;
|
||||
expect(text).toContain("Tool execution was aborted");
|
||||
expect(text).not.toContain("Tool execution was aborted.:");
|
||||
}
|
||||
expect(toolResultEvent).toBeUndefined();
|
||||
|
||||
const assistantEnd = events.find(
|
||||
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
|
||||
e.type === "message_end" && e.message.role === "assistant",
|
||||
);
|
||||
expect(assistantEnd).toBeDefined();
|
||||
if (assistantEnd?.message.role !== "assistant") return;
|
||||
expect(assistantEnd.message.stopReason).toBe("aborted");
|
||||
expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false);
|
||||
});
|
||||
|
||||
it("should skip remaining tool calls when steering is queued", async () => {
|
||||
@@ -1188,6 +1129,55 @@ describe("agentLoopContinue with AgentMessage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
const controller = new AbortController();
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
controller.abort("stop after tool");
|
||||
return {
|
||||
content: [{ type: "text", text: `original: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
|
||||
let hookSawAbortedSignal = false;
|
||||
const mock = createMockModel({
|
||||
responses: [
|
||||
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
|
||||
{ content: ["done"] },
|
||||
],
|
||||
});
|
||||
const config: AgentLoopConfig = {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
afterToolCall: async (_context, signal) => {
|
||||
hookSawAbortedSignal = signal?.aborted === true;
|
||||
return { content: [{ type: "text", text: "rewritten after abort" }] };
|
||||
},
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream);
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(hookSawAbortedSignal).toBe(true);
|
||||
const toolEnd = events.find(e => e.type === "tool_execution_end");
|
||||
expect(toolEnd).toBeDefined();
|
||||
if (toolEnd?.type === "tool_execution_end") {
|
||||
expect(toolEnd.isError).toBe(false);
|
||||
expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten after abort" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces afterToolCall errors as a tool error result", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
@@ -1307,6 +1297,9 @@ describe("agentLoopContinue with AgentMessage", () => {
|
||||
],
|
||||
stopReason: "length",
|
||||
},
|
||||
{
|
||||
content: ["ok, I will split the write into smaller chunks"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context";
|
||||
import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types";
|
||||
import type { Message, Tool } from "@oh-my-pi/pi-ai";
|
||||
import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "../src/append-only-context";
|
||||
import type { AgentContext, AgentTool } from "../src/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
createHarmonyAuditEvent,
|
||||
detectHarmonyLeak,
|
||||
@@ -9,7 +7,9 @@ import {
|
||||
isHarmonyLeakMitigationTarget,
|
||||
recoverHarmonyToolCall,
|
||||
signalListLabel,
|
||||
} from "../src/harmony-leak";
|
||||
} from "@oh-my-pi/pi-agent-core/harmony-leak";
|
||||
import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import corpus from "./fixtures/harmony-leak-corpus.json" with { type: "json" };
|
||||
import { createAssistantMessage } from "./helpers";
|
||||
|
||||
|
||||
@@ -503,37 +503,38 @@ describe("aggregateAgentRunSummaries / aggregateAgentRunCoverage", () => {
|
||||
describe("onRunEnd is non-fatal", () => {
|
||||
it("swallows thrown errors and still resolves agentLoop().result() normally", async () => {
|
||||
const tracer = new RecordingTracer();
|
||||
const warnings: unknown[][] = [];
|
||||
const realWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args);
|
||||
};
|
||||
try {
|
||||
const mock = createMockModel({ responses: [{ content: ["ok"] }] });
|
||||
const stream = agentLoop(
|
||||
[createUserMessage("hi")],
|
||||
{ systemPrompt: ["sys"], messages: [], tools: [] },
|
||||
{
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
telemetry: {
|
||||
tracer,
|
||||
onRunEnd: () => {
|
||||
throw new Error("user code is buggy");
|
||||
},
|
||||
const warnings: { code: string; message: string }[] = [];
|
||||
const mock = createMockModel({ responses: [{ content: ["ok"] }] });
|
||||
const stream = agentLoop(
|
||||
[createUserMessage("hi")],
|
||||
{ systemPrompt: ["sys"], messages: [], tools: [] },
|
||||
{
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
telemetry: {
|
||||
tracer,
|
||||
onRunEnd: () => {
|
||||
throw new Error("user code is buggy");
|
||||
},
|
||||
// The failure is surfaced through the telemetry-warning channel, not a
|
||||
// rejection. `console.warn` is only the no-hook fallback inside
|
||||
// `emitTelemetryWarning`, so capture via the hook for a deterministic assert.
|
||||
onTelemetryWarning: warning => {
|
||||
warnings.push({ code: warning.code, message: warning.message });
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
mock.stream,
|
||||
);
|
||||
const messages = await stream.result();
|
||||
expect(messages.length).toBe(2);
|
||||
} finally {
|
||||
console.warn = realWarn;
|
||||
}
|
||||
// The wrapper must surface the failure via console.warn, not via rejection.
|
||||
},
|
||||
undefined,
|
||||
mock.stream,
|
||||
);
|
||||
const messages = await stream.result();
|
||||
expect(messages.length).toBe(2);
|
||||
// `fireOnRunEnd` can run in `runLoop`'s finally, after `result()` resolves — flush
|
||||
// the trailing microtasks of the fire-and-forget agent loop before asserting.
|
||||
await Bun.sleep(5);
|
||||
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(1);
|
||||
expect(String(warnings[0][0])).toContain("onRunEnd");
|
||||
expect(warnings.some(w => w.code === "on_run_end_failed" && w.message.includes("onRunEnd"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { ExponentialYield, yieldIfDue } from "../src/utils/yield";
|
||||
import { ExponentialYield, yieldIfDue } from "@oh-my-pi/pi-agent-core/utils/yield";
|
||||
|
||||
const YIELD_INTERVAL_MS = 50;
|
||||
const YIELD_CLOCK_STEP_MS = 60_000;
|
||||
|
||||
@@ -2,7 +2,36 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.10.5] - 2026-06-08
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the OAuth subpath export `@oh-my-pi/pi-ai/utils/oauth` → `@oh-my-pi/pi-ai/oauth` (and `@oh-my-pi/pi-ai/utils/oauth/*` → `@oh-my-pi/pi-ai/oauth/*`, e.g. `oauth/types`, `oauth/callback-server`, `oauth/openai-codex`) after relocating the OAuth implementation out of `utils/oauth/` into `registry/oauth/`. The high-level OAuth API (`getOAuthProviders`, `refreshOAuthToken`, `getOAuthApiKey`, `registerOAuthProvider`, `unregisterOAuthProviders`, `getOAuthProvider`) and the `OAuth*` types stay exported from the package root, unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429
|
||||
- Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body).
|
||||
- Refactored provider configuration to a single-source registry (`registry/`, renamed from `provider-registry/` with its `providers/` subdir flattened up). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Provider defs live directly under `registry/`; thin provider-specific login flows are inlined into the def file, while heavier provider-local OAuth flows and the shared OAuth flow infra (`callback-server`, `pkce`, `google-oauth-shared`, `types`, runtime `index`) now live together under `registry/oauth/` (previously split across `provider-registry/providers/oauth/` and `utils/oauth/`). The non-OAuth API-key paste/validation helpers (`api-key-login`, `api-key-validation`) sit beside the defs in `registry/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disabled OpenAI Codex Responses stream obfuscation by sending `stream_options.include_obfuscation=false`, reducing raw WebSocket/SSE debug noise and bandwidth.
|
||||
- Interrupted OpenAI Codex Responses streams that emit long runs of whitespace-only tool-call argument deltas, preventing degenerate WebSocket/SSE responses from filling the raw stream buffer indefinitely.
|
||||
- Preserved streaming responses when Anthropic emits unrecognized content_block envelopes by ignoring unknown blocks and continuing to emit known content
|
||||
- Applied cache control to the most recent tool result block when building Anthropic OAuth payloads without a preceding text block, enabling ephemeral caching for tool-result-only messages
|
||||
- Kept Anthropic sampling parameters (temperature, top_p, top_k) when thinking is explicitly disabled
|
||||
- Fixed raw Anthropic SSE handling by parsing event frames with strict JSON parsing and matching event-type validation, surfacing malformed frames as stream errors instead of repairing them
|
||||
- Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output
|
||||
- Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types
|
||||
- Fixed Anthropic thinking request preparation by clamping `max_tokens` to provider/model limits and adjusting thinking budgets to a valid value
|
||||
- Fixed Anthropic request shaping around forced tool choice, unsigned thinking replay, prompt-cache marker placement, non-Anthropic bearer gateways, Foundry TLS loading, and strict tool-schema normalization so malformed or incompatible request payloads are rejected locally or shaped consistently before streaming
|
||||
- Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments.
|
||||
- Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `<id>.<secret>` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106))
|
||||
- Fixed Moonshot `kimi-k2.6` (and any future `kimi-k2.x`) discovered via `MOONSHOT_API_KEY` stalling on first turn with no output. The `moonshotModelManagerOptions` discovery mapper only marked ids containing `"thinking"` as `reasoning: true`, so dynamic `kimi-k2.6` entries fell through with `reasoning: false`; the openai-completions z.ai branch was then skipped and the request reached Moonshot with no `thinking` parameter at all. Moonshot K2.6 requires an explicit `thinking: {type}` field (the same native-API wire shape #1838 introduced `thinking.keep` for), so the server held the stream silently. The mapper now stamps `reasoning: true`, vision input, and default `thinking` metadata on every `kimi-k2.x` id, restoring the explicit `thinking: {type: "disabled"|"enabled"}` wire body the Moonshot endpoint expects. ([#2113](https://github.com/can1357/oh-my-pi/issues/2113))
|
||||
|
||||
## [15.10.4] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added `anthropic-client-platform` (`desktop_app`) and `anthropic-client-version` (`1.11187.4`) headers to the Anthropic request fingerprint for OAuth sessions
|
||||
@@ -20,8 +49,8 @@
|
||||
- Removed the synthetic `<turn-aborted>` developer guidance note that `transformMessages` injected after an aborted/errored assistant turn (and its `turn-aborted-guidance.md` prompt). The per-call synthetic `"aborted"` tool results already tell the model the turn's tools were terminated, so the extra "verify current state before retrying" note was redundant — and it biased the model toward second-guessing a deliberate user interrupt when the turn was resumed.
|
||||
- Removed the legacy Anthropic first-user-message skip for `<system-reminder>` blocks now that synthetic reminders no longer travel as user messages.
|
||||
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
|
||||
@@ -3045,4 +3074,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
## [0.9.4] - 2025-11-26
|
||||
|
||||
Initial release with multi-provider LLM support.
|
||||
Initial release with multi-provider LLM support.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.10.4",
|
||||
"version": "15.10.5",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
@@ -120,13 +120,21 @@
|
||||
"types": "./src/utils/discovery/*.ts",
|
||||
"import": "./src/utils/discovery/*.ts"
|
||||
},
|
||||
"./utils/oauth": {
|
||||
"types": "./src/utils/oauth/index.ts",
|
||||
"import": "./src/utils/oauth/index.ts"
|
||||
"./oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./utils/oauth/*": {
|
||||
"types": "./src/utils/oauth/*.ts",
|
||||
"import": "./src/utils/oauth/*.ts"
|
||||
"./oauth/*": {
|
||||
"types": "./src/registry/oauth/*.ts",
|
||||
"import": "./src/registry/oauth/*.ts"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./src/registry/index.ts",
|
||||
"import": "./src/registry/index.ts"
|
||||
},
|
||||
"./registry/oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./utils/schema": {
|
||||
"types": "./src/utils/schema/index.ts",
|
||||
|
||||
@@ -38,10 +38,10 @@ import {
|
||||
} from "../src/provider-models/openai-compat";
|
||||
import { getGitLabDuoModels } from "../src/providers/gitlab-duo";
|
||||
import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants";
|
||||
import type { OAuthProvider } from "../src/registry/oauth/types";
|
||||
import type { Model } from "../src/types";
|
||||
import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity";
|
||||
import { fetchCodexModels } from "../src/utils/discovery/codex";
|
||||
import type { OAuthProvider } from "../src/utils/oauth/types";
|
||||
|
||||
const packageRoot = path.join(import.meta.dir, "..");
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
type StoredAuthCredential,
|
||||
} from "../auth-storage";
|
||||
import type { OAuthCredentials } from "../registry/oauth/types";
|
||||
import type { Provider } from "../types";
|
||||
import type { UsageReport } from "../usage";
|
||||
import type { OAuthCredentials } from "../utils/oauth/types";
|
||||
import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
|
||||
import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
|
||||
|
||||
|
||||
+24
-338
@@ -13,6 +13,9 @@ import * as path from "node:path";
|
||||
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { ApiKeyResolver } from "./auth-retry";
|
||||
import { isUsageLimitError } from "./rate-limit-utils";
|
||||
import { getProviderDefinition } from "./registry";
|
||||
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
|
||||
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
|
||||
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
||||
import type { Provider } from "./types";
|
||||
import type {
|
||||
@@ -32,11 +35,6 @@ import { antigravityUsageProvider } from "./usage/google-antigravity";
|
||||
import { kimiUsageProvider } from "./usage/kimi";
|
||||
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
|
||||
import { zaiUsageProvider } from "./usage/zai";
|
||||
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
|
||||
import { loginDeepSeek } from "./utils/oauth/deepseek";
|
||||
import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
|
||||
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
|
||||
import { loginXiaomi, loginXiaomiTokenPlan } from "./utils/oauth/xiaomi";
|
||||
|
||||
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
|
||||
|
||||
@@ -1554,345 +1552,33 @@ export class AuthStorage {
|
||||
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
let credentials: OAuthCredentials;
|
||||
const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
|
||||
const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
|
||||
await this.set(provider, newCredential);
|
||||
};
|
||||
const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
|
||||
switch (provider) {
|
||||
case "anthropic": {
|
||||
const { loginAnthropic } = await import("./utils/oauth/anthropic");
|
||||
credentials = await loginAnthropic({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "xai-oauth": {
|
||||
const { loginXAIOAuth } = await import("./utils/oauth/xai-oauth");
|
||||
credentials = await loginXAIOAuth({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "alibaba-coding-plan": {
|
||||
const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan");
|
||||
const apiKey = await loginAlibabaCodingPlan(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "github-copilot": {
|
||||
const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot");
|
||||
credentials = await loginGitHubCopilot({
|
||||
onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
|
||||
onPrompt: ctrl.onPrompt,
|
||||
onProgress: ctrl.onProgress,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "google-gemini-cli": {
|
||||
const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli");
|
||||
credentials = await loginGeminiCli({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "google-antigravity": {
|
||||
const { loginAntigravity } = await import("./utils/oauth/google-antigravity");
|
||||
credentials = await loginAntigravity({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "openai-codex": {
|
||||
const { loginOpenAICodex } = await import("./utils/oauth/openai-codex");
|
||||
credentials = await loginOpenAICodex({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "openai-codex-device": {
|
||||
// Device/headless flow — stores credentials under "openai-codex" so the
|
||||
// provider can pick them up without a separate provider configuration.
|
||||
const deviceCredentials = await loginOpenAICodexDevice(ctrl);
|
||||
const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials };
|
||||
await this.#upsertOAuthCredential("openai-codex", newCredential);
|
||||
return;
|
||||
}
|
||||
case "gitlab-duo": {
|
||||
const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo");
|
||||
credentials = await loginGitLabDuo({
|
||||
...ctrl,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "kimi-code": {
|
||||
const { loginKimi } = await import("./utils/oauth/kimi");
|
||||
credentials = await loginKimi(ctrl);
|
||||
break;
|
||||
}
|
||||
case "kilo": {
|
||||
const { loginKilo } = await import("./utils/oauth/kilo");
|
||||
credentials = await loginKilo(ctrl);
|
||||
break;
|
||||
}
|
||||
case "cursor": {
|
||||
const { loginCursor } = await import("./utils/oauth/cursor");
|
||||
credentials = await loginCursor(
|
||||
url => ctrl.onAuth({ url }),
|
||||
ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "perplexity": {
|
||||
const { loginPerplexity } = await import("./utils/oauth/perplexity");
|
||||
credentials = await loginPerplexity(ctrl);
|
||||
break;
|
||||
}
|
||||
case "huggingface": {
|
||||
const { loginHuggingface } = await import("./utils/oauth/huggingface");
|
||||
const apiKey = await loginHuggingface(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "opencode-zen":
|
||||
case "opencode-go": {
|
||||
const { loginOpenCode } = await import("./utils/oauth/opencode");
|
||||
const apiKey = await loginOpenCode(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "lm-studio": {
|
||||
const { loginLmStudio } = await import("./utils/oauth/lm-studio");
|
||||
const apiKey = await loginLmStudio(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "ollama": {
|
||||
const { loginOllama } = await import("./utils/oauth/ollama");
|
||||
const apiKey = await loginOllama(ctrl);
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "ollama-cloud": {
|
||||
const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud");
|
||||
const apiKey = await loginOllamaCloud(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "cerebras": {
|
||||
const { loginCerebras } = await import("./utils/oauth/cerebras");
|
||||
const apiKey = await loginCerebras(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "deepseek": {
|
||||
const apiKey = await loginDeepSeek(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "fireworks": {
|
||||
const { loginFireworks } = await import("./utils/oauth/fireworks");
|
||||
const apiKey = await loginFireworks(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "firepass": {
|
||||
const { loginFirepass } = await import("./utils/oauth/firepass");
|
||||
const apiKey = await loginFirepass(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "wafer-pass": {
|
||||
const { loginWaferPass } = await import("./utils/oauth/wafer");
|
||||
const apiKey = await loginWaferPass(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "wafer-serverless": {
|
||||
const { loginWaferServerless } = await import("./utils/oauth/wafer");
|
||||
const apiKey = await loginWaferServerless(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "zai": {
|
||||
const { loginZai } = await import("./utils/oauth/zai");
|
||||
const apiKey = await loginZai(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "zhipu-coding-plan": {
|
||||
const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu");
|
||||
const apiKey = await loginZhipuCodingPlan(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "qianfan": {
|
||||
const { loginQianfan } = await import("./utils/oauth/qianfan");
|
||||
const apiKey = await loginQianfan(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "minimax-code": {
|
||||
const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code");
|
||||
const apiKey = await loginMiniMaxCode(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "minimax-code-cn": {
|
||||
const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code");
|
||||
const apiKey = await loginMiniMaxCodeCn(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "synthetic": {
|
||||
const { loginSynthetic } = await import("./utils/oauth/synthetic");
|
||||
const apiKey = await loginSynthetic(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "tavily": {
|
||||
const { loginTavily } = await import("./utils/oauth/tavily");
|
||||
const apiKey = await loginTavily(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "venice": {
|
||||
const { loginVenice } = await import("./utils/oauth/venice");
|
||||
const apiKey = await loginVenice(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "litellm": {
|
||||
const { loginLiteLLM } = await import("./utils/oauth/litellm");
|
||||
const apiKey = await loginLiteLLM(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "moonshot": {
|
||||
const { loginMoonshot } = await import("./utils/oauth/moonshot");
|
||||
const apiKey = await loginMoonshot(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "kagi": {
|
||||
const { loginKagi } = await import("./utils/oauth/kagi");
|
||||
const apiKey = await loginKagi(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "nanogpt": {
|
||||
const { loginNanoGPT } = await import("./utils/oauth/nanogpt");
|
||||
const apiKey = await loginNanoGPT(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "openrouter": {
|
||||
const { loginOpenRouter } = await import("./utils/oauth/openrouter");
|
||||
const apiKey = await loginOpenRouter(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "together": {
|
||||
const { loginTogether } = await import("./utils/oauth/together");
|
||||
const apiKey = await loginTogether(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "cloudflare-ai-gateway": {
|
||||
const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway");
|
||||
const apiKey = await loginCloudflareAiGateway(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "vercel-ai-gateway": {
|
||||
const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway");
|
||||
const apiKey = await loginVercelAiGateway(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "vllm": {
|
||||
const { loginVllm } = await import("./utils/oauth/vllm");
|
||||
const apiKey = await loginVllm(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "parallel": {
|
||||
const { loginParallel } = await import("./utils/oauth/parallel");
|
||||
const apiKey = await loginParallel(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "qwen-portal": {
|
||||
const { loginQwenPortal } = await import("./utils/oauth/qwen-portal");
|
||||
const apiKey = await loginQwenPortal(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "nvidia": {
|
||||
const { loginNvidia } = await import("./utils/oauth/nvidia");
|
||||
const apiKey = await loginNvidia(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "xiaomi": {
|
||||
const apiKey = await loginXiaomi(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "xiaomi-token-plan-sgp": {
|
||||
const apiKey = await loginXiaomiTokenPlan(ctrl, "sgp");
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "xiaomi-token-plan-ams": {
|
||||
const apiKey = await loginXiaomiTokenPlan(ctrl, "ams");
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "xiaomi-token-plan-cn": {
|
||||
const apiKey = await loginXiaomiTokenPlan(ctrl, "cn");
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "zenmux": {
|
||||
const { loginZenMux } = await import("./utils/oauth/zenmux");
|
||||
const apiKey = await loginZenMux(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
const customProvider = getOAuthProvider(provider);
|
||||
if (!customProvider) {
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
const customLoginResult = await customProvider.login({
|
||||
onAuth: info => ctrl.onAuth(info),
|
||||
onProgress: ctrl.onProgress,
|
||||
onPrompt: ctrl.onPrompt,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (typeof customLoginResult === "string") {
|
||||
await saveApiKeyCredential(customLoginResult);
|
||||
return;
|
||||
}
|
||||
credentials = customLoginResult;
|
||||
break;
|
||||
}
|
||||
// Built-in registry first, then runtime-registered extension providers.
|
||||
const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
|
||||
if (!def?.login) {
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
const newCredential: OAuthCredential = { type: "oauth", ...credentials };
|
||||
await this.#upsertOAuthCredential(provider, newCredential);
|
||||
const result = await def.login({
|
||||
onAuth: ctrl.onAuth,
|
||||
onProgress: ctrl.onProgress,
|
||||
onPrompt: ctrl.onPrompt,
|
||||
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
// Some flows (e.g. ollama) return "" to signal that no key was entered.
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
await saveApiKeyCredential(result);
|
||||
return;
|
||||
}
|
||||
const newCredential: OAuthCredential = { type: "oauth", ...result };
|
||||
await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ export * from "./providers/openai-completions";
|
||||
export * from "./providers/openai-responses";
|
||||
export * from "./providers/synthetic";
|
||||
export * from "./rate-limit-utils";
|
||||
export * from "./registry";
|
||||
export * from "./stream";
|
||||
export * from "./types";
|
||||
export * from "./usage";
|
||||
@@ -43,13 +44,6 @@ export * from "./usage/zai";
|
||||
export * from "./utils/anthropic-auth";
|
||||
export * from "./utils/discovery";
|
||||
export * from "./utils/event-stream";
|
||||
export * from "./utils/oauth";
|
||||
export type {
|
||||
OAuthCredentials,
|
||||
OAuthProvider,
|
||||
OAuthProviderId,
|
||||
OAuthProviderInfo,
|
||||
} from "./utils/oauth/types";
|
||||
export * from "./utils/overflow";
|
||||
export * from "./utils/retry";
|
||||
export * from "./utils/schema";
|
||||
|
||||
+11
-11
@@ -38413,9 +38413,9 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 2.4,
|
||||
"cacheRead": 0.12,
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.06,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 512000,
|
||||
@@ -40159,13 +40159,13 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.684,
|
||||
"output": 3.42,
|
||||
"cacheRead": 0.144,
|
||||
"input": 0.6799999999999999,
|
||||
"output": 3.41,
|
||||
"cacheRead": 0.33999999999999997,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"maxTokens": 262142,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
@@ -43304,13 +43304,13 @@
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.684,
|
||||
"output": 3.42,
|
||||
"cacheRead": 0.144,
|
||||
"input": 0.6799999999999999,
|
||||
"output": 3.41,
|
||||
"cacheRead": 0.33999999999999997,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"maxTokens": 262142,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
|
||||
@@ -1,364 +1,43 @@
|
||||
/**
|
||||
* Unified provider descriptors — single source of truth for provider metadata
|
||||
* used by both runtime model discovery (model-registry.ts) and catalog
|
||||
* generation (generate-models.ts).
|
||||
* Provider descriptors and the default-model map, derived from the single-source
|
||||
* provider registry (`../registry`).
|
||||
*
|
||||
* The descriptor/catalog types and guards now live in the registry; they are
|
||||
* re-exported here for back-compat with `generate-models.ts` and existing
|
||||
* `@oh-my-pi/pi-ai/provider-models` consumers.
|
||||
*/
|
||||
import type { ModelManagerOptions } from "../model-manager";
|
||||
import type { Api, KnownProvider } from "../types";
|
||||
import type { OAuthProvider } from "../utils/oauth/types";
|
||||
import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google";
|
||||
import { ollamaCloudModelManagerOptions } from "./ollama";
|
||||
import {
|
||||
alibabaCodingPlanModelManagerOptions,
|
||||
anthropicModelManagerOptions,
|
||||
cerebrasModelManagerOptions,
|
||||
cloudflareAiGatewayModelManagerOptions,
|
||||
deepseekModelManagerOptions,
|
||||
firepassModelManagerOptions,
|
||||
fireworksModelManagerOptions,
|
||||
githubCopilotModelManagerOptions,
|
||||
groqModelManagerOptions,
|
||||
huggingfaceModelManagerOptions,
|
||||
kiloModelManagerOptions,
|
||||
kimiCodeModelManagerOptions,
|
||||
litellmModelManagerOptions,
|
||||
lmStudioModelManagerOptions,
|
||||
mistralModelManagerOptions,
|
||||
moonshotModelManagerOptions,
|
||||
nanoGptModelManagerOptions,
|
||||
nvidiaModelManagerOptions,
|
||||
ollamaModelManagerOptions,
|
||||
openaiModelManagerOptions,
|
||||
opencodeGoModelManagerOptions,
|
||||
opencodeZenModelManagerOptions,
|
||||
openrouterModelManagerOptions,
|
||||
qianfanModelManagerOptions,
|
||||
qwenPortalModelManagerOptions,
|
||||
syntheticModelManagerOptions,
|
||||
togetherModelManagerOptions,
|
||||
veniceModelManagerOptions,
|
||||
vercelAiGatewayModelManagerOptions,
|
||||
vllmModelManagerOptions,
|
||||
waferPassModelManagerOptions,
|
||||
waferServerlessModelManagerOptions,
|
||||
xaiModelManagerOptions,
|
||||
xaiOAuthModelManagerOptions,
|
||||
xiaomiModelManagerOptions,
|
||||
zenmuxModelManagerOptions,
|
||||
zhipuCodingPlanModelManagerOptions,
|
||||
} from "./openai-compat";
|
||||
import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special";
|
||||
import { PROVIDER_REGISTRY } from "../registry";
|
||||
import type { ProviderDescriptor } from "../registry/types";
|
||||
import type { KnownProvider } from "../types";
|
||||
|
||||
/** Catalog discovery configuration for providers that support endpoint-based model listing. */
|
||||
export interface CatalogDiscoveryConfig {
|
||||
/** Human-readable name for log messages. */
|
||||
label: string;
|
||||
/** Environment variables to check for API keys during catalog generation. */
|
||||
envVars: string[];
|
||||
/** OAuth provider for credential refresh during catalog generation. */
|
||||
oauthProvider?: OAuthProvider;
|
||||
/** When true, catalog discovery proceeds even without credentials. */
|
||||
allowUnauthenticated?: boolean;
|
||||
}
|
||||
|
||||
/** Unified provider descriptor used by both runtime discovery and catalog generation. */
|
||||
export interface ProviderDescriptor {
|
||||
providerId: KnownProvider;
|
||||
createModelManagerOptions(config: { apiKey?: string; baseUrl?: string }): ModelManagerOptions<Api>;
|
||||
/** Preferred model ID when no explicit selection is made. */
|
||||
defaultModel: string;
|
||||
/** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */
|
||||
allowUnauthenticated?: boolean;
|
||||
/** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */
|
||||
dynamicModelsAuthoritative?: boolean;
|
||||
/** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */
|
||||
catalogDiscovery?: CatalogDiscoveryConfig;
|
||||
}
|
||||
|
||||
/** A provider descriptor that has catalog discovery configured. */
|
||||
export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig };
|
||||
|
||||
/** Type guard for descriptors with catalog discovery. */
|
||||
export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor {
|
||||
return d.catalogDiscovery != null;
|
||||
}
|
||||
|
||||
/** Whether catalog discovery may run without provider credentials. */
|
||||
export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean {
|
||||
return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false;
|
||||
}
|
||||
|
||||
function descriptor(
|
||||
providerId: KnownProvider,
|
||||
defaultModel: string,
|
||||
createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"],
|
||||
options: Pick<ProviderDescriptor, "allowUnauthenticated" | "dynamicModelsAuthoritative"> = {},
|
||||
): ProviderDescriptor {
|
||||
return {
|
||||
providerId,
|
||||
defaultModel,
|
||||
createModelManagerOptions,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(
|
||||
label: string,
|
||||
envVars: string[],
|
||||
options: Pick<CatalogDiscoveryConfig, "oauthProvider" | "allowUnauthenticated"> = {},
|
||||
): CatalogDiscoveryConfig {
|
||||
return {
|
||||
label,
|
||||
envVars,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogDescriptor(
|
||||
providerId: KnownProvider,
|
||||
defaultModel: string,
|
||||
createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"],
|
||||
catalogDiscovery: CatalogDiscoveryConfig,
|
||||
options: Pick<ProviderDescriptor, "allowUnauthenticated" | "dynamicModelsAuthoritative"> = {},
|
||||
): ProviderDescriptor {
|
||||
return {
|
||||
...descriptor(providerId, defaultModel, createModelManagerOptions, options),
|
||||
catalogDiscovery,
|
||||
};
|
||||
}
|
||||
export * from "../registry/types";
|
||||
|
||||
/**
|
||||
* All standard providers. Special providers (google-antigravity, google-gemini-cli,
|
||||
* openai-codex) are handled separately because they require different config shapes.
|
||||
* Runtime model-discovery descriptors: every registry provider that exposes a
|
||||
* standard model-manager factory. Special-managed providers
|
||||
* (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in
|
||||
* the coding-agent runtime and are excluded here.
|
||||
*/
|
||||
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
||||
descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"alibaba-coding-plan",
|
||||
"qwen3.5-plus",
|
||||
config => alibabaCodingPlanModelManagerOptions(config),
|
||||
catalog("Alibaba Coding Plan", ["ALIBABA_CODING_PLAN_API_KEY"]),
|
||||
),
|
||||
descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)),
|
||||
descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"huggingface",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
config => huggingfaceModelManagerOptions(config),
|
||||
catalog("Hugging Face", ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"cerebras",
|
||||
"zai-glm-4.6",
|
||||
config => cerebrasModelManagerOptions(config),
|
||||
catalog("Cerebras", ["CEREBRAS_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"fireworks",
|
||||
"kimi-k2.6",
|
||||
config => fireworksModelManagerOptions(config),
|
||||
catalog("Fireworks", ["FIREWORKS_API_KEY"]),
|
||||
),
|
||||
// Fire Pass does not expose a /v1/models endpoint — the API returns HTTP 403
|
||||
// on any catalog-discovery request, so dynamic model listing is not feasible.
|
||||
//
|
||||
// The single model `kimi-k2.6-turbo` is seeded via the `prevModelsJson`
|
||||
// fallback in `generate-models.ts`, which preserves entries from the previous
|
||||
// catalog snapshot when a provider does not surface them dynamically.
|
||||
//
|
||||
// IMPORTANT: Do NOT delete the firepass section from models.json. No
|
||||
// descriptor here produces that entry dynamically — removing it from
|
||||
// models.json would permanently drop the model from the catalog with no
|
||||
// automated mechanism to restore it.
|
||||
descriptor("firepass", "kimi-k2.6-turbo", config => firepassModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"wafer-pass",
|
||||
"GLM-5.1",
|
||||
config => waferPassModelManagerOptions(config),
|
||||
catalog("Wafer Pass", ["WAFER_PASS_API_KEY"], { oauthProvider: "wafer-pass" }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"wafer-serverless",
|
||||
"GLM-5.1",
|
||||
config => waferServerlessModelManagerOptions(config),
|
||||
catalog("Wafer Serverless", ["WAFER_SERVERLESS_API_KEY"], { oauthProvider: "wafer-serverless" }),
|
||||
),
|
||||
descriptor("xai", "grok-4-fast-non-reasoning", config => xaiModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"xai-oauth",
|
||||
"grok-4.3",
|
||||
config => xaiOAuthModelManagerOptions(config),
|
||||
catalog("xAI Grok OAuth (SuperGrok)", ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], {
|
||||
oauthProvider: "xai-oauth",
|
||||
}),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"deepseek",
|
||||
"deepseek-v4-pro",
|
||||
config => deepseekModelManagerOptions(config),
|
||||
catalog("DeepSeek", ["DEEPSEEK_API_KEY"]),
|
||||
),
|
||||
descriptor("mistral", "devstral-medium-latest", config => mistralModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"nvidia",
|
||||
"nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
config => nvidiaModelManagerOptions(config),
|
||||
catalog("NVIDIA", ["NVIDIA_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"nanogpt",
|
||||
"openai/gpt-5.4",
|
||||
config => nanoGptModelManagerOptions(config),
|
||||
catalog("NanoGPT", ["NANO_GPT_API_KEY"]),
|
||||
),
|
||||
descriptor("opencode-zen", "claude-sonnet-4-6", config => opencodeZenModelManagerOptions(config)),
|
||||
descriptor("opencode-go", "kimi-k2.5", config => opencodeGoModelManagerOptions(config)),
|
||||
catalogDescriptor(
|
||||
"openrouter",
|
||||
"openai/gpt-5.4",
|
||||
config => openrouterModelManagerOptions(config),
|
||||
catalog("OpenRouter", ["OPENROUTER_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"kilo",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
config => kiloModelManagerOptions(config),
|
||||
catalog("Kilo Gateway", ["KILO_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"vercel-ai-gateway",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
config => vercelAiGatewayModelManagerOptions(config),
|
||||
catalog("Vercel AI Gateway", ["VERCEL_AI_GATEWAY_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"ollama",
|
||||
"gpt-oss:20b",
|
||||
config => ollamaModelManagerOptions(config),
|
||||
catalog("Ollama", ["OLLAMA_API_KEY"]),
|
||||
{ allowUnauthenticated: true },
|
||||
),
|
||||
catalogDescriptor(
|
||||
"ollama-cloud",
|
||||
"gpt-oss:120b",
|
||||
config => ollamaCloudModelManagerOptions(config),
|
||||
catalog("Ollama Cloud", ["OLLAMA_CLOUD_API_KEY"], { oauthProvider: "ollama-cloud" }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"cloudflare-ai-gateway",
|
||||
"claude-sonnet-4-5",
|
||||
config => cloudflareAiGatewayModelManagerOptions(config),
|
||||
catalog("Cloudflare AI Gateway", ["CLOUDFLARE_AI_GATEWAY_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"kimi-code",
|
||||
"kimi-k2.5",
|
||||
config => kimiCodeModelManagerOptions(config),
|
||||
catalog("Kimi Code", ["KIMI_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"qwen-portal",
|
||||
"coder-model",
|
||||
config => qwenPortalModelManagerOptions(config),
|
||||
catalog("Qwen Portal", ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], { oauthProvider: "qwen-portal" }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"synthetic",
|
||||
"hf:zai-org/GLM-5.1",
|
||||
config => syntheticModelManagerOptions(config),
|
||||
catalog("Synthetic", ["SYNTHETIC_API_KEY"]),
|
||||
{ dynamicModelsAuthoritative: true },
|
||||
),
|
||||
catalogDescriptor(
|
||||
"venice",
|
||||
"llama-3.3-70b",
|
||||
config => veniceModelManagerOptions(config),
|
||||
catalog("Venice", ["VENICE_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"litellm",
|
||||
"claude-opus-4-6",
|
||||
config => litellmModelManagerOptions(config),
|
||||
catalog("LiteLLM", ["LITELLM_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
descriptor("lm-studio", "llama-3-8b", config => lmStudioModelManagerOptions(config), { allowUnauthenticated: true }),
|
||||
catalogDescriptor(
|
||||
"vllm",
|
||||
"gpt-oss-20b",
|
||||
config => vllmModelManagerOptions(config),
|
||||
catalog("vLLM", ["VLLM_API_KEY"], { allowUnauthenticated: true }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"moonshot",
|
||||
"kimi-k2.5",
|
||||
config => moonshotModelManagerOptions(config),
|
||||
catalog("Moonshot", ["MOONSHOT_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"qianfan",
|
||||
"deepseek-v3.2",
|
||||
config => qianfanModelManagerOptions(config),
|
||||
catalog("Qianfan", ["QIANFAN_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"together",
|
||||
"moonshotai/Kimi-K2.5",
|
||||
config => togetherModelManagerOptions(config),
|
||||
catalog("Together", ["TOGETHER_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"xiaomi",
|
||||
"mimo-v2-flash",
|
||||
config => xiaomiModelManagerOptions(config),
|
||||
catalog("Xiaomi", ["XIAOMI_API_KEY"]),
|
||||
),
|
||||
descriptor("xiaomi-token-plan-sgp", "mimo-v2.5", config =>
|
||||
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }),
|
||||
),
|
||||
descriptor("xiaomi-token-plan-ams", "mimo-v2.5", config =>
|
||||
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }),
|
||||
),
|
||||
descriptor("xiaomi-token-plan-cn", "mimo-v2.5", config =>
|
||||
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"zenmux",
|
||||
"anthropic/claude-opus-4.6",
|
||||
config => zenmuxModelManagerOptions(config),
|
||||
catalog("ZenMux", ["ZENMUX_API_KEY"]),
|
||||
),
|
||||
catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
|
||||
catalogDescriptor(
|
||||
"zhipu-coding-plan",
|
||||
"glm-5.1",
|
||||
config => zhipuCodingPlanModelManagerOptions(config),
|
||||
catalog("Zhipu Coding Plan", ["ZHIPU_API_KEY"]),
|
||||
),
|
||||
descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)),
|
||||
descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)),
|
||||
descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), {
|
||||
allowUnauthenticated: true,
|
||||
}),
|
||||
catalogDescriptor(
|
||||
"cursor",
|
||||
"claude-sonnet-4-6",
|
||||
config => cursorModelManagerOptions(config),
|
||||
catalog("Cursor", ["CURSOR_API_KEY"], { oauthProvider: "cursor" }),
|
||||
),
|
||||
] as const;
|
||||
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = PROVIDER_REGISTRY.flatMap(provider => {
|
||||
const { createModelManagerOptions } = provider;
|
||||
if (!createModelManagerOptions || provider.specialModelManager) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
providerId: provider.id,
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
createModelManagerOptions,
|
||||
allowUnauthenticated: provider.allowUnauthenticated,
|
||||
dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative,
|
||||
catalogDiscovery: provider.catalogDiscovery,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
/** Default model IDs for all known providers, built from descriptors + special providers. */
|
||||
export const DEFAULT_MODEL_PER_PROVIDER: Record<KnownProvider, string> = {
|
||||
...Object.fromEntries(PROVIDER_DESCRIPTORS.map(d => [d.providerId, d.defaultModel])),
|
||||
// Providers not in PROVIDER_DESCRIPTORS (special auth or no standard discovery)
|
||||
"alibaba-coding-plan": "qwen3.5-plus",
|
||||
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
|
||||
"google-antigravity": "gemini-3-pro-high",
|
||||
"google-gemini-cli": "gemini-2.5-pro",
|
||||
"google-vertex": "gemini-3-pro-preview",
|
||||
minimax: "MiniMax-M2.5",
|
||||
"minimax-code": "MiniMax-M2.5",
|
||||
"minimax-code-cn": "MiniMax-M2.5",
|
||||
"openai-codex": "gpt-5.4",
|
||||
"gitlab-duo": "duo-chat-sonnet-4-5",
|
||||
} as Record<KnownProvider, string>;
|
||||
/** Default model IDs for all known providers, derived from the registry. */
|
||||
export const DEFAULT_MODEL_PER_PROVIDER: Record<KnownProvider, string> = Object.fromEntries(
|
||||
PROVIDER_REGISTRY.filter(provider => provider.defaultModel != null).map(
|
||||
provider => [provider.id, provider.defaultModel] as [string, string],
|
||||
),
|
||||
) as Record<KnownProvider, string>;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Fallback context-window / max-output-token values for models discovered
|
||||
* without limit metadata.
|
||||
*
|
||||
* Kept in a dependency-free leaf module (rather than `openai-compat.ts`) so the
|
||||
* model-discovery helpers in `utils/discovery/*` can import them without pulling
|
||||
* the package root barrel (`@oh-my-pi/pi-ai`) into the model-manager init graph,
|
||||
* which would otherwise form an import cycle through the provider registry.
|
||||
*/
|
||||
export const UNK_CONTEXT_WINDOW = 222_222;
|
||||
export const UNK_MAX_TOKENS = 8_888;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effort } from "../effort";
|
||||
import type { ModelManagerOptions } from "../model-manager";
|
||||
import { getBundledModels } from "../models";
|
||||
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
|
||||
import type { Api, Model, Provider, ThinkingConfig } from "../types";
|
||||
import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
|
||||
import {
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
type OpenAICompatibleModelRecord,
|
||||
} from "../utils/discovery/openai-compatible";
|
||||
import { toFireworksPublicModelId } from "../utils/fireworks-model-id";
|
||||
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
||||
import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references";
|
||||
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
||||
@@ -441,7 +442,7 @@ function isLikelyNanoGptTextModelId(id: string): boolean {
|
||||
|
||||
type SimpleProviderConfig = { apiKey?: string; baseUrl?: string };
|
||||
|
||||
function createSimpleOpenAICompletionsOptions(
|
||||
export function createSimpleOpenAICompletionsOptions(
|
||||
providerId: Parameters<typeof getBundledModels>[0],
|
||||
defaultBaseUrl: string,
|
||||
config?: SimpleProviderConfig,
|
||||
@@ -1916,12 +1917,23 @@ export function moonshotModelManagerOptions(
|
||||
const reference = references.get(defaults.id);
|
||||
const model = mapWithBundledReference(entry, defaults, reference);
|
||||
const id = model.id.toLowerCase();
|
||||
const isThinking = id.includes("thinking");
|
||||
const isVision = id.includes("vision") || id.includes("vl") || id.includes("k2.5");
|
||||
// Moonshot's K2.x family (K2.5, K2.6, kimi-k2-thinking, …) is reasoning-capable
|
||||
// and vision-capable on the native API. Without these flags the openai-completions
|
||||
// path skips the z.ai-format `thinking` block, and Moonshot K2.6 stalls on first
|
||||
// turn because its endpoint expects an explicit `thinking: {type}` (#2113). Match
|
||||
// the bundled K2.5 metadata for every K2.x id we discover.
|
||||
const isKimiK2Reasoning = id.includes("thinking") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
|
||||
const isVision =
|
||||
id.includes("vision") || id.includes("vl") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
|
||||
return {
|
||||
...model,
|
||||
reasoning: isThinking || model.reasoning,
|
||||
reasoning: isKimiK2Reasoning || model.reasoning,
|
||||
input: isVision ? ["text", "image"] : model.input,
|
||||
thinking:
|
||||
model.thinking ??
|
||||
(isKimiK2Reasoning
|
||||
? { mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High }
|
||||
: undefined),
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -2385,8 +2397,7 @@ export function anthropicModelManagerOptions(
|
||||
// Models.dev provider descriptors for generate-models.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UNK_CONTEXT_WINDOW = 222_222;
|
||||
export const UNK_MAX_TOKENS = 8_888;
|
||||
export { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants";
|
||||
|
||||
/** Describes how to map models.dev API data for a single provider. */
|
||||
export interface ModelsDevProviderDescriptor {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
|
||||
import type { Message } from "../types";
|
||||
import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
||||
/**
|
||||
* Infer whether the current request to Copilot is user-initiated or agent-initiated.
|
||||
* Accepts `unknown[]` because providers may pass pre-converted message shapes.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
|
||||
*/
|
||||
|
||||
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
|
||||
import type { Api, Context, Model } from "../types";
|
||||
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
||||
import {
|
||||
type OpenAIAnthropicApiFormat,
|
||||
type OpenAIAnthropicShimOptions,
|
||||
|
||||
@@ -142,6 +142,12 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be
|
||||
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
|
||||
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
||||
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
|
||||
// Provider/model failure mode: Codex can keep a response alive by streaming
|
||||
// whitespace-only function-call argument deltas forever. Those frames count as
|
||||
// transport activity, so idle timers never fire; cap the run before raw debug
|
||||
// buffers and partial JSON grow without semantic progress.
|
||||
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256;
|
||||
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024;
|
||||
|
||||
function isCodexStreamProgressEvent(event: unknown): boolean {
|
||||
if (isOpenAIResponsesProgressEvent(event)) return true;
|
||||
@@ -235,6 +241,20 @@ interface CodexStreamRuntime {
|
||||
providerRetryAttempt: number;
|
||||
sawTerminalEvent: boolean;
|
||||
canSafelyReplayWebsocketOverSse: boolean;
|
||||
whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState;
|
||||
}
|
||||
|
||||
interface CodexWhitespaceToolCallArgumentsDeltaState {
|
||||
itemId: string;
|
||||
outputIndex?: number;
|
||||
consecutiveEvents: number;
|
||||
consecutiveChars: number;
|
||||
firstSequenceNumber?: number;
|
||||
lastSequenceNumber?: number;
|
||||
}
|
||||
|
||||
interface CodexWhitespaceToolCallArgumentsDeltaInterruption {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CodexStreamProcessingContext {
|
||||
@@ -945,9 +965,88 @@ function createCodexStreamRuntime(initial: {
|
||||
providerRetryAttempt: 0,
|
||||
sawTerminalEvent: false,
|
||||
canSafelyReplayWebsocketOverSse: true,
|
||||
whitespaceToolCallArgumentsDelta: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resetWhitespaceToolCallArgumentsDelta(runtime: CodexStreamRuntime): void {
|
||||
runtime.whitespaceToolCallArgumentsDelta = undefined;
|
||||
}
|
||||
|
||||
function isJsonWhitespaceOnly(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x20) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readOptionalInteger(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function observeWhitespaceToolCallArgumentsDelta(
|
||||
runtime: CodexStreamRuntime,
|
||||
rawEvent: Record<string, unknown>,
|
||||
delta: string,
|
||||
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
|
||||
if (!isJsonWhitespaceOnly(delta)) {
|
||||
resetWhitespaceToolCallArgumentsDelta(runtime);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const itemId =
|
||||
typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0
|
||||
? rawEvent.item_id
|
||||
: (runtime.currentItem?.id ?? "");
|
||||
const outputIndex = readOptionalInteger(rawEvent.output_index);
|
||||
const sequenceNumber = readOptionalInteger(rawEvent.sequence_number);
|
||||
let state = runtime.whitespaceToolCallArgumentsDelta;
|
||||
if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) {
|
||||
state = {
|
||||
itemId,
|
||||
outputIndex,
|
||||
consecutiveEvents: 0,
|
||||
consecutiveChars: 0,
|
||||
firstSequenceNumber: sequenceNumber,
|
||||
};
|
||||
runtime.whitespaceToolCallArgumentsDelta = state;
|
||||
}
|
||||
|
||||
state.consecutiveEvents += 1;
|
||||
state.consecutiveChars += delta.length;
|
||||
state.lastSequenceNumber = sequenceNumber;
|
||||
if (
|
||||
state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT &&
|
||||
state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const itemLabel = itemId ? ` for item ${itemId}` : "";
|
||||
const sequenceLabel =
|
||||
state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined
|
||||
? ""
|
||||
: `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`;
|
||||
return {
|
||||
message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`,
|
||||
};
|
||||
}
|
||||
|
||||
function interruptWhitespaceToolCallArgumentsDelta(
|
||||
requestSetup: CodexRequestSetup,
|
||||
runtime: CodexStreamRuntime,
|
||||
interruption: CodexWhitespaceToolCallArgumentsDeltaInterruption,
|
||||
): never {
|
||||
const error = new Error(interruption.message);
|
||||
requestSetup.requestAbortController.abort(error);
|
||||
runtime.websocketState?.connection?.close("degenerate-tool-call");
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function processCodexResponseStream(
|
||||
context: CodexStreamProcessingContext,
|
||||
runtime: CodexStreamRuntime,
|
||||
@@ -984,8 +1083,9 @@ function handleCodexStreamEvent(args: {
|
||||
runtime: CodexStreamRuntime;
|
||||
rawEvent: Record<string, unknown>;
|
||||
firstTokenTime?: number;
|
||||
requestSetup: CodexRequestSetup;
|
||||
}): number | undefined {
|
||||
const { model, output, stream, runtime, rawEvent } = args;
|
||||
const { model, output, stream, runtime, rawEvent, requestSetup } = args;
|
||||
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
|
||||
if (!eventType) return args.firstTokenTime;
|
||||
|
||||
@@ -994,6 +1094,7 @@ function handleCodexStreamEvent(args: {
|
||||
let firstTokenTime = args.firstTokenTime;
|
||||
|
||||
if (eventType === "response.output_item.added") {
|
||||
resetWhitespaceToolCallArgumentsDelta(runtime);
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
const item = rawEvent.item as CodexEventItem;
|
||||
runtime.currentItem = item;
|
||||
@@ -1055,11 +1156,13 @@ function handleCodexStreamEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "response.function_call_arguments.delta") {
|
||||
handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
|
||||
const interruption = handleToolCallArgumentsDelta(runtime, rawEvent, stream, output, blockIndex);
|
||||
if (interruption) interruptWhitespaceToolCallArgumentsDelta(requestSetup, runtime, interruption);
|
||||
return firstTokenTime;
|
||||
}
|
||||
|
||||
if (eventType === "response.function_call_arguments.done") {
|
||||
resetWhitespaceToolCallArgumentsDelta(runtime);
|
||||
handleToolCallArgumentsDone(runtime.currentItem, runtime.currentBlock, rawEvent);
|
||||
return firstTokenTime;
|
||||
}
|
||||
@@ -1075,6 +1178,7 @@ function handleCodexStreamEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "response.output_item.done") {
|
||||
resetWhitespaceToolCallArgumentsDelta(runtime);
|
||||
handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex);
|
||||
return firstTokenTime;
|
||||
}
|
||||
@@ -1206,15 +1310,18 @@ function handleMessageTextDelta(
|
||||
}
|
||||
|
||||
function handleToolCallArgumentsDelta(
|
||||
currentItem: CodexEventItem | null,
|
||||
currentBlock: CodexOutputBlock | null,
|
||||
runtime: CodexStreamRuntime,
|
||||
rawEvent: Record<string, unknown>,
|
||||
stream: AssistantMessageEventStream,
|
||||
output: AssistantMessage,
|
||||
blockIndex: () => number,
|
||||
): void {
|
||||
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
|
||||
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
|
||||
const currentItem = runtime.currentItem;
|
||||
const currentBlock = runtime.currentBlock;
|
||||
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return undefined;
|
||||
const delta = (rawEvent as { delta?: string }).delta || "";
|
||||
const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta);
|
||||
if (interruption) return interruption;
|
||||
currentBlock.partialJson += delta;
|
||||
const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
|
||||
if (throttled) {
|
||||
@@ -1222,6 +1329,7 @@ function handleToolCallArgumentsDelta(
|
||||
currentBlock.lastParseLen = throttled.parsedLen;
|
||||
}
|
||||
stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handleToolCallArgumentsDone(
|
||||
|
||||
@@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" };
|
||||
import type { Effort } from "../effort";
|
||||
import { getSupportedEfforts } from "../model-thinking";
|
||||
import { calculateCost } from "../models";
|
||||
import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
|
||||
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
|
||||
import { getEnvApiKey } from "../stream";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
@@ -53,8 +55,6 @@ import {
|
||||
iterateWithIdleTimeout,
|
||||
} from "../utils/idle-iterator";
|
||||
import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
|
||||
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
||||
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
|
||||
import { notifyProviderResponse } from "../utils/provider-response";
|
||||
import { callWithCopilotModelRetry } from "../utils/retry";
|
||||
import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ResponseInput,
|
||||
ResponseStreamEvent,
|
||||
} from "openai/resources/responses/responses";
|
||||
import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
|
||||
import { getEnvApiKey } from "../stream";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
getOpenAIStreamIdleTimeoutMs,
|
||||
iterateWithIdleTimeout,
|
||||
} from "../utils/idle-iterator";
|
||||
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
||||
import { notifyProviderResponse } from "../utils/provider-response";
|
||||
import { callWithCopilotModelRetry } from "../utils/retry";
|
||||
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
||||
|
||||
+13
-18
@@ -1,27 +1,12 @@
|
||||
/**
|
||||
* Alibaba Coding Plan login flow.
|
||||
*
|
||||
* Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1.
|
||||
*
|
||||
* This is not OAuth - it's a simple API key flow:
|
||||
* 1. Open browser to Alibaba Cloud DashScope API key settings
|
||||
* 2. User copies their API key
|
||||
* 3. User pastes the API key into the CLI
|
||||
*/
|
||||
|
||||
import { alibabaCodingPlanModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { validateOpenAICompatibleApiKey } from "./api-key-validation";
|
||||
import type { OAuthController } from "./types";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const AUTH_URL = "https://modelstudio.console.alibabacloud.com/";
|
||||
const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
|
||||
const VALIDATION_MODEL = "qwen3.5-plus";
|
||||
|
||||
/**
|
||||
* Login to Alibaba Coding Plan.
|
||||
*
|
||||
* Opens browser to API keys page, prompts user to paste their API key.
|
||||
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
|
||||
*/
|
||||
export async function loginAlibabaCodingPlan(options: OAuthController): Promise<string> {
|
||||
if (!options.onPrompt) {
|
||||
throw new Error("Alibaba Coding Plan login requires onPrompt callback");
|
||||
@@ -57,3 +42,13 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const alibabaCodingPlanProvider = {
|
||||
id: "alibaba-coding-plan",
|
||||
name: "Alibaba Coding Plan",
|
||||
defaultModel: "qwen3.5-plus",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Alibaba Coding Plan", envVars: ["ALIBABA_CODING_PLAN_API_KEY"] },
|
||||
envKeys: "ALIBABA_CODING_PLAN_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const amazonBedrockProvider = {
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock",
|
||||
defaultModel: "us.anthropic.claude-opus-4-6-v1",
|
||||
// Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains.
|
||||
envKeys: () => {
|
||||
const hasEcsCredentials =
|
||||
!!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
||||
const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
|
||||
if (
|
||||
$env.AWS_PROFILE ||
|
||||
($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
|
||||
$env.AWS_BEARER_TOKEN_BEDROCK ||
|
||||
hasEcsCredentials ||
|
||||
hasWebIdentity
|
||||
) {
|
||||
return "<authenticated>";
|
||||
}
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { $pickenv } from "@oh-my-pi/pi-utils";
|
||||
import { anthropicModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { isFoundryEnabled } from "../utils/foundry";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const anthropicProvider = {
|
||||
id: "anthropic",
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
defaultModel: "claude-opus-4-6",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config),
|
||||
// Foundry mode optionally switches Anthropic auth to enterprise gateway credentials.
|
||||
envKeys: () =>
|
||||
isFoundryEnabled()
|
||||
? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY")
|
||||
: $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"),
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginAnthropic } = await import("./oauth/anthropic");
|
||||
return loginAnthropic(cb);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { refreshAnthropicToken } = await import("./oauth/anthropic");
|
||||
return refreshAnthropicToken(credentials.refresh);
|
||||
},
|
||||
callbackPort: 54545,
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
+22
-3
@@ -6,8 +6,12 @@
|
||||
* optionally validate it, and return the trimmed key.
|
||||
*/
|
||||
|
||||
import { validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey } from "./api-key-validation";
|
||||
import type { OAuthController } from "./types";
|
||||
import {
|
||||
validateAnthropicCompatibleApiKey,
|
||||
validateApiKeyAgainstModelsEndpoint,
|
||||
validateOpenAICompatibleApiKey,
|
||||
} from "./api-key-validation";
|
||||
import type { OAuthController } from "./oauth/types";
|
||||
|
||||
type ChatCompletionsValidation = {
|
||||
kind: "chat-completions";
|
||||
@@ -16,6 +20,13 @@ type ChatCompletionsValidation = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
type AnthropicMessagesValidation = {
|
||||
kind: "anthropic-messages";
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type ModelsEndpointValidation = {
|
||||
kind: "models-endpoint";
|
||||
provider: string;
|
||||
@@ -34,7 +45,7 @@ export type ApiKeyLoginConfig = {
|
||||
/** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */
|
||||
placeholder: string;
|
||||
/** Validation strategy, or `null` to skip validation. */
|
||||
validation: ChatCompletionsValidation | ModelsEndpointValidation | null;
|
||||
validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null;
|
||||
};
|
||||
|
||||
export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise<string> {
|
||||
@@ -72,6 +83,14 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
|
||||
model: config.validation.model,
|
||||
signal: options.signal,
|
||||
});
|
||||
} else if (config.validation.kind === "anthropic-messages") {
|
||||
await validateAnthropicCompatibleApiKey({
|
||||
provider: config.validation.provider,
|
||||
apiKey: trimmed,
|
||||
baseUrl: config.validation.baseUrl,
|
||||
model: config.validation.model,
|
||||
signal: options.signal,
|
||||
});
|
||||
} else {
|
||||
await validateApiKeyAgainstModelsEndpoint({
|
||||
provider: config.validation.provider,
|
||||
+52
@@ -5,6 +5,13 @@ type OpenAICompatibleValidationOptions = {
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
type AnthropicCompatibleValidationOptions = {
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ModelListValidationOptions = {
|
||||
provider: string;
|
||||
@@ -15,6 +22,11 @@ type ModelListValidationOptions = {
|
||||
|
||||
const VALIDATION_TIMEOUT_MS = 15_000;
|
||||
|
||||
function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key against an OpenAI-compatible chat completions endpoint.
|
||||
*
|
||||
@@ -56,6 +68,46 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key against an Anthropic-compatible messages endpoint.
|
||||
*/
|
||||
export async function validateAnthropicCompatibleApiKey(options: AnthropicCompatibleValidationOptions): Promise<void> {
|
||||
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
||||
const baseUrl = normalizeAnthropicCompatibleBaseUrl(options.baseUrl);
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": options.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
let details = "";
|
||||
try {
|
||||
details = (await response.text()).trim();
|
||||
} catch {
|
||||
// ignore body parse errors, status is enough
|
||||
}
|
||||
|
||||
const message = details
|
||||
? `${options.provider} API key validation failed (${response.status}): ${details}`
|
||||
: `${options.provider} API key validation failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key against a provider models endpoint.
|
||||
*
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cerebrasModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const loginCerebras = createApiKeyLogin({
|
||||
providerLabel: "Cerebras",
|
||||
authUrl: "https://cloud.cerebras.ai/platform/",
|
||||
instructions: "Copy your API key from the Cerebras dashboard",
|
||||
promptMessage: "Paste your Cerebras API key",
|
||||
placeholder: "csk-...",
|
||||
validation: {
|
||||
kind: "chat-completions",
|
||||
provider: "Cerebras",
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
model: "gpt-oss-120b",
|
||||
},
|
||||
});
|
||||
|
||||
export const cerebrasProvider = {
|
||||
id: "cerebras",
|
||||
name: "Cerebras",
|
||||
defaultModel: "zai-glm-4.6",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Cerebras", envVars: ["CEREBRAS_API_KEY"] },
|
||||
envKeys: "CEREBRAS_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginCerebras(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
+13
-12
@@ -1,15 +1,6 @@
|
||||
/**
|
||||
* Cloudflare AI Gateway login flow.
|
||||
*
|
||||
* Cloudflare AI Gateway proxies upstream model providers.
|
||||
*
|
||||
* This is not OAuth - it's a simple API key flow:
|
||||
* 1. Open Cloudflare AI Gateway docs/dashboard
|
||||
* 2. User copies their Cloudflare AI Gateway token/API key
|
||||
* 3. User pastes the API key into the CLI
|
||||
*/
|
||||
|
||||
import type { OAuthController } from "./types";
|
||||
import { cloudflareAiGatewayModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/";
|
||||
|
||||
@@ -46,3 +37,13 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const cloudflareAiGatewayProvider = {
|
||||
id: "cloudflare-ai-gateway",
|
||||
name: "Cloudflare AI Gateway",
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Cloudflare AI Gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"] },
|
||||
envKeys: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cursorModelManagerOptions } from "../provider-models/special";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const cursorProvider = {
|
||||
id: "cursor",
|
||||
name: "Cursor (Claude, GPT, etc.)",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" },
|
||||
envKeys: "CURSOR_ACCESS_TOKEN",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginCursor } = await import("./oauth/cursor");
|
||||
return loginCursor(
|
||||
url => cb.onAuth({ url }),
|
||||
cb.onProgress ? () => cb.onProgress?.("Waiting for browser authentication...") : undefined,
|
||||
);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { refreshCursorToken } = await import("./oauth/cursor");
|
||||
return refreshCursorToken(credentials.refresh);
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* DeepSeek login flow (API key paste against https://api.deepseek.com).
|
||||
*
|
||||
* Validation hits `GET /v1/models` so it authenticates the key without
|
||||
* depending on a specific model being enabled on the account. The previous
|
||||
* implementation issued a chat-completion against `deepseek-v4-pro`, which
|
||||
* 404s for accounts without that preview model even when the key is valid.
|
||||
*/
|
||||
import { deepseekModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
import type { OAuthController, OAuthPrompt } from "./types";
|
||||
import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const innerLogin = createApiKeyLogin({
|
||||
providerLabel: "DeepSeek",
|
||||
@@ -22,17 +16,10 @@ const innerLogin = createApiKeyLogin({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a pasted DeepSeek API key.
|
||||
*
|
||||
* Users frequently copy keys out of `curl` snippets that include the
|
||||
* `Authorization: Bearer …` prefix. Strip it so validation does not fail
|
||||
* with a confusing 401, and reject obviously empty input early.
|
||||
*/
|
||||
export function normalizeDeepSeekApiKey(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed; // let the shared factory throw the canonical "API key is required"
|
||||
return trimmed;
|
||||
}
|
||||
const stripped = trimmed.replace(/^bearer\b\s*/i, "");
|
||||
if (!stripped) {
|
||||
@@ -51,3 +38,13 @@ export const loginDeepSeek = async (options: OAuthController): Promise<string> =
|
||||
: options;
|
||||
return innerLogin(wrapped);
|
||||
};
|
||||
|
||||
export const deepseekProvider = {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultModel: "deepseek-v4-pro",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "DeepSeek", envVars: ["DEEPSEEK_API_KEY"] },
|
||||
envKeys: "DEEPSEEK_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginDeepSeek(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PROVIDER_REGISTRY } from "./registry";
|
||||
|
||||
/**
|
||||
* Providers whose OAuth flow needs a pasted code/redirect URL rather than a
|
||||
* local callback server. Consumed by the coding-agent login UX.
|
||||
*/
|
||||
export const PASTE_CODE_LOGIN_PROVIDERS: ReadonlySet<string> = new Set(
|
||||
PROVIDER_REGISTRY.filter(p => p.pasteCodeFlow).map(p => p.id),
|
||||
);
|
||||
@@ -1,3 +1,8 @@
|
||||
import { firepassModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
/**
|
||||
* Fire Pass login flow.
|
||||
*
|
||||
@@ -7,8 +12,6 @@
|
||||
* pings the chat completions endpoint with the router id directly.
|
||||
* See https://docs.fireworks.ai/firepass.
|
||||
*/
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
|
||||
export const loginFirepass = createApiKeyLogin({
|
||||
providerLabel: "Fire Pass",
|
||||
authUrl: "https://app.fireworks.ai/settings/users/api-keys",
|
||||
@@ -22,3 +25,12 @@ export const loginFirepass = createApiKeyLogin({
|
||||
model: "accounts/fireworks/routers/kimi-k2p6-turbo",
|
||||
},
|
||||
});
|
||||
|
||||
export const firepassProvider = {
|
||||
id: "firepass",
|
||||
name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)",
|
||||
defaultModel: "kimi-k2.6-turbo",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config),
|
||||
envKeys: "FIREPASS_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginFirepass(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fireworksModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const loginFireworks = createApiKeyLogin({
|
||||
providerLabel: "Fireworks",
|
||||
authUrl: "https://app.fireworks.ai/settings/users/api-keys",
|
||||
instructions: "Create or copy your Fireworks API key",
|
||||
promptMessage: "Paste your Fireworks API key",
|
||||
placeholder: "fw_...",
|
||||
validation: {
|
||||
kind: "models-endpoint",
|
||||
provider: "Fireworks",
|
||||
modelsUrl: "https://api.fireworks.ai/inference/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
export const fireworksProvider = {
|
||||
id: "fireworks",
|
||||
name: "Fireworks",
|
||||
defaultModel: "kimi-k2.6",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Fireworks", envVars: ["FIREWORKS_API_KEY"] },
|
||||
envKeys: "FIREWORKS_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginFireworks(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { githubCopilotModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const githubCopilotProvider = {
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
defaultModel: "gpt-4o",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config),
|
||||
envKeys: "COPILOT_GITHUB_TOKEN",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginGitHubCopilot } = await import("./oauth/github-copilot");
|
||||
return loginGitHubCopilot({
|
||||
onAuth: (url, instructions) => cb.onAuth({ url, instructions }),
|
||||
onPrompt: cb.onPrompt,
|
||||
onProgress: cb.onProgress,
|
||||
signal: cb.signal,
|
||||
});
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { refreshGitHubCopilotToken } = await import("./oauth/github-copilot");
|
||||
return refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const gitlabDuoProvider = {
|
||||
id: "gitlab-duo",
|
||||
name: "GitLab Duo",
|
||||
defaultModel: "duo-chat-sonnet-4-5",
|
||||
envKeys: "GITLAB_TOKEN",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginGitLabDuo } = await import("./oauth/gitlab-duo");
|
||||
return loginGitLabDuo(cb);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { refreshGitLabDuoToken } = await import("./oauth/gitlab-duo");
|
||||
return refreshGitLabDuoToken(credentials);
|
||||
},
|
||||
callbackPort: 8080,
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const googleAntigravityProvider = {
|
||||
id: "google-antigravity",
|
||||
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
||||
defaultModel: "gemini-3-pro-high",
|
||||
specialModelManager: true,
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginAntigravity } = await import("./oauth/google-antigravity");
|
||||
return loginAntigravity(cb);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
if (!credentials.projectId) {
|
||||
throw new Error("Antigravity credentials missing projectId");
|
||||
}
|
||||
const { refreshAntigravityToken } = await import("./oauth/google-antigravity");
|
||||
return refreshAntigravityToken(credentials.refresh, credentials.projectId);
|
||||
},
|
||||
callbackPort: 51121,
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const googleGeminiCliProvider = {
|
||||
id: "google-gemini-cli",
|
||||
name: "Google Cloud Code Assist (Gemini CLI)",
|
||||
defaultModel: "gemini-2.5-pro",
|
||||
specialModelManager: true,
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginGeminiCli } = await import("./oauth/google-gemini-cli");
|
||||
return loginGeminiCli(cb);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
if (!credentials.projectId) {
|
||||
throw new Error("Google Cloud credentials missing projectId");
|
||||
}
|
||||
const { refreshGoogleCloudToken } = await import("./oauth/google-gemini-cli");
|
||||
return refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
|
||||
},
|
||||
callbackPort: 8085,
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import { googleVertexModelManagerOptions } from "../provider-models/google";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
||||
|
||||
function hasVertexAdcCredentials(): boolean {
|
||||
if (cachedVertexAdcCredentialsExists === null) {
|
||||
const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
if (gacPath) {
|
||||
cachedVertexAdcCredentialsExists = fs.existsSync(gacPath);
|
||||
} else {
|
||||
cachedVertexAdcCredentialsExists = fs.existsSync(
|
||||
path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"),
|
||||
);
|
||||
}
|
||||
}
|
||||
return cachedVertexAdcCredentialsExists;
|
||||
}
|
||||
|
||||
export const googleVertexProvider = {
|
||||
id: "google-vertex",
|
||||
name: "Google Vertex AI",
|
||||
defaultModel: "gemini-3-pro-preview",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config),
|
||||
allowUnauthenticated: true,
|
||||
// Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials.
|
||||
envKeys: () => {
|
||||
if ($env.GOOGLE_CLOUD_API_KEY) {
|
||||
return $env.GOOGLE_CLOUD_API_KEY;
|
||||
}
|
||||
const hasCredentials = hasVertexAdcCredentials();
|
||||
const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT);
|
||||
const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION);
|
||||
if (hasCredentials && hasProject && hasLocation) {
|
||||
return "<authenticated>";
|
||||
}
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { googleModelManagerOptions } from "../provider-models/google";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const googleProvider = {
|
||||
id: "google",
|
||||
name: "Google Gemini",
|
||||
defaultModel: "gemini-2.5-pro",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config),
|
||||
envKeys: "GEMINI_API_KEY",
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { groqModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const groqProvider = {
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
defaultModel: "openai/gpt-oss-120b",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config),
|
||||
envKeys: "GROQ_API_KEY",
|
||||
} as const satisfies ProviderDefinition;
|
||||
+14
-19
@@ -1,29 +1,14 @@
|
||||
/**
|
||||
* Hugging Face Inference login flow.
|
||||
*
|
||||
* Hugging Face Inference Providers expose an OpenAI-compatible endpoint via
|
||||
* https://router.huggingface.co/v1.
|
||||
*
|
||||
* This is an API key flow:
|
||||
* 1. Open browser to Hugging Face token settings
|
||||
* 2. User creates/copies a token with Inference Providers permission
|
||||
* 3. User pastes the token into the CLI
|
||||
*/
|
||||
|
||||
import { $pickenv } from "@oh-my-pi/pi-utils";
|
||||
import { huggingfaceModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { validateOpenAICompatibleApiKey } from "./api-key-validation";
|
||||
import type { OAuthController } from "./types";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const AUTH_URL =
|
||||
"https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained";
|
||||
const API_BASE_URL = "https://router.huggingface.co/v1";
|
||||
const VALIDATION_MODEL = "openai/gpt-oss-120b";
|
||||
|
||||
/**
|
||||
* Login to Hugging Face Inference Providers.
|
||||
*
|
||||
* Opens browser to token settings, prompts user to paste their API key.
|
||||
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
|
||||
*/
|
||||
export async function loginHuggingface(options: OAuthController): Promise<string> {
|
||||
if (!options.onPrompt) {
|
||||
throw new Error("Hugging Face login requires onPrompt callback");
|
||||
@@ -60,3 +45,13 @@ export async function loginHuggingface(options: OAuthController): Promise<string
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const huggingfaceProvider = {
|
||||
id: "huggingface",
|
||||
name: "Hugging Face Inference",
|
||||
defaultModel: "deepseek-ai/DeepSeek-R1",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => huggingfaceModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Hugging Face", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] },
|
||||
envKeys: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"),
|
||||
login: (cb: OAuthLoginCallbacks) => loginHuggingface(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./derived";
|
||||
export * from "./oauth";
|
||||
export * from "./registry";
|
||||
export * from "./types";
|
||||
@@ -1,14 +1,5 @@
|
||||
/**
|
||||
* Kagi login flow.
|
||||
*
|
||||
* Kagi web search uses an API key from the account settings page.
|
||||
* This is an API key flow:
|
||||
* 1. Open browser to Kagi API settings
|
||||
* 2. User copies API key
|
||||
* 3. User pastes key into CLI
|
||||
*/
|
||||
|
||||
import type { OAuthController } from "./types";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
const AUTH_URL = "https://kagi.com/settings/api";
|
||||
|
||||
@@ -45,3 +36,10 @@ export async function loginKagi(options: OAuthController): Promise<string> {
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const kagiProvider = {
|
||||
id: "kagi",
|
||||
name: "Kagi",
|
||||
envKeys: "KAGI_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginKagi(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
import { kiloModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthController, OAuthCredentials } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth";
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
@@ -15,9 +17,6 @@ interface KiloDeviceAuthPollResponse {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Kilo Gateway OAuth (device code flow).
|
||||
*/
|
||||
export async function loginKilo(callbacks: OAuthController): Promise<OAuthCredentials> {
|
||||
const initiateResponse = await fetch(`${KILO_DEVICE_AUTH_BASE_URL}/codes`, {
|
||||
method: "POST",
|
||||
@@ -85,3 +84,13 @@ export async function loginKilo(callbacks: OAuthController): Promise<OAuthCreden
|
||||
|
||||
throw new Error("Authentication timed out. Please try again.");
|
||||
}
|
||||
|
||||
export const kiloProvider = {
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
defaultModel: "anthropic/claude-sonnet-4.5",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => kiloModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Kilo Gateway", envVars: ["KILO_API_KEY"], allowUnauthenticated: true },
|
||||
envKeys: "KILO_API_KEY",
|
||||
login: loginKilo,
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { kimiCodeModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const kimiCodeProvider = {
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
defaultModel: "kimi-k2.5",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] },
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginKimi } = await import("./oauth/kimi");
|
||||
return loginKimi(cb);
|
||||
},
|
||||
refreshToken: async (credentials: OAuthCredentials) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { refreshKimiToken } = await import("./oauth/kimi");
|
||||
return refreshKimiToken(credentials.refresh);
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -1,15 +1,6 @@
|
||||
/**
|
||||
* LiteLLM login flow.
|
||||
*
|
||||
* LiteLLM is an OpenAI-compatible proxy that routes requests to many upstream providers.
|
||||
*
|
||||
* This is not OAuth - it's a simple API key flow:
|
||||
* 1. Open browser to LiteLLM docs/dashboard
|
||||
* 2. User copies their LiteLLM API key
|
||||
* 3. User pastes the API key into the CLI
|
||||
*/
|
||||
|
||||
import type { OAuthController } from "./types";
|
||||
import { litellmModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
|
||||
|
||||
@@ -45,3 +36,13 @@ export async function loginLiteLLM(options: OAuthController): Promise<string> {
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const litellmProvider = {
|
||||
id: "litellm",
|
||||
name: "LiteLLM",
|
||||
defaultModel: "claude-opus-4-6",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "LiteLLM", envVars: ["LITELLM_API_KEY"], allowUnauthenticated: true },
|
||||
envKeys: "LITELLM_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginLiteLLM(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { lmStudioModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
const PROVIDER_ID = "lm-studio";
|
||||
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
|
||||
|
||||
export async function loginLmStudio(options: OAuthController): Promise<string> {
|
||||
if (!options.onPrompt) {
|
||||
throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
|
||||
}
|
||||
|
||||
const apiKey = await options.onPrompt({
|
||||
message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
|
||||
placeholder: DEFAULT_LOCAL_TOKEN,
|
||||
allowEmpty: true,
|
||||
});
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
|
||||
const trimmed = apiKey.trim();
|
||||
return trimmed || DEFAULT_LOCAL_TOKEN;
|
||||
}
|
||||
|
||||
export const lmStudioProvider = {
|
||||
id: "lm-studio",
|
||||
name: "LM Studio (Local OpenAI-compatible)",
|
||||
defaultModel: "llama-3-8b",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config),
|
||||
allowUnauthenticated: true,
|
||||
envKeys: "LM_STUDIO_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginLmStudio(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const minimaxCodeCnProvider = {
|
||||
id: "minimax-code-cn",
|
||||
name: "MiniMax Coding Plan (China)",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
envKeys: "MINIMAX_CODE_CN_API_KEY",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginMiniMaxCodeCn } = await import("./oauth/minimax-code");
|
||||
return loginMiniMaxCodeCn(cb);
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const minimaxCodeProvider = {
|
||||
id: "minimax-code",
|
||||
name: "MiniMax Coding Plan (International)",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
envKeys: "MINIMAX_CODE_API_KEY",
|
||||
login: async (cb: OAuthLoginCallbacks) => {
|
||||
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
|
||||
const { loginMiniMaxCode } = await import("./oauth/minimax-code");
|
||||
return loginMiniMaxCode(cb);
|
||||
},
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ProviderDefinition } from "./types";
|
||||
|
||||
export const minimaxProvider = {
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
envKeys: "MINIMAX_API_KEY",
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mistralModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const mistralProvider = {
|
||||
id: "mistral",
|
||||
name: "Mistral",
|
||||
defaultModel: "devstral-medium-latest",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config),
|
||||
envKeys: "MISTRAL_API_KEY",
|
||||
} as const satisfies ProviderDefinition;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { moonshotModelManagerOptions } from "../provider-models/openai-compat";
|
||||
import { createApiKeyLogin } from "./api-key-login";
|
||||
import type { OAuthLoginCallbacks } from "./oauth/types";
|
||||
import type { ModelManagerConfig, ProviderDefinition } from "./types";
|
||||
|
||||
export const loginMoonshot = createApiKeyLogin({
|
||||
providerLabel: "Moonshot",
|
||||
authUrl: "https://platform.moonshot.ai/console/api-keys",
|
||||
instructions: "Copy your API key from the Moonshot dashboard",
|
||||
promptMessage: "Paste your Moonshot API key",
|
||||
placeholder: "sk-...",
|
||||
validation: {
|
||||
kind: "models-endpoint",
|
||||
provider: "moonshot",
|
||||
modelsUrl: "https://api.moonshot.ai/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
export const moonshotProvider = {
|
||||
id: "moonshot",
|
||||
name: "Moonshot (Kimi API)",
|
||||
defaultModel: "kimi-k2.5",
|
||||
createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config),
|
||||
catalogDiscovery: { label: "Moonshot", envVars: ["MOONSHOT_API_KEY"] },
|
||||
envKeys: "MOONSHOT_API_KEY",
|
||||
login: (cb: OAuthLoginCallbacks) => loginMoonshot(cb),
|
||||
} as const satisfies ProviderDefinition;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user