Compare commits

...

15 Commits

Author SHA1 Message Date
Timmy 7427865e46 Adding more slash commands 2026-03-31 11:33:41 +01:00
Timmy ff5f9c76fd Bump version to 0.8.4 2026-03-31 11:32:10 +01:00
dave 641bbfbe2e storkit: done 448_story_send_oauth_login_link_via_chat_when_credentials_are_missing 2026-03-31 10:28:06 +00:00
dave 5516ec4595 storkit: merge 448_story_send_oauth_login_link_via_chat_when_credentials_are_missing 2026-03-31 10:28:02 +00:00
Timmy 762467efd4 Allowing stat in claude permissions 2026-03-31 11:22:15 +01:00
Timmy 3f54bda360 Updating sha2 2026-03-31 11:21:50 +01:00
dave 4d1e388a48 storkit: done 447_bug_element_tab_completion_display_name_breaks_bot_command_matching 2026-03-31 10:18:24 +00:00
dave 10be86587a storkit: merge 447_bug_element_tab_completion_display_name_breaks_bot_command_matching 2026-03-31 10:18:21 +00:00
dave 6a10591413 storkit: done 446_story_oauth_login_button_in_web_ui 2026-03-31 10:08:43 +00:00
dave 321c88e05e storkit: merge 446_story_oauth_login_button_in_web_ui 2026-03-31 10:08:40 +00:00
dave 23562dfa61 storkit: create 448_story_send_oauth_login_link_via_chat_when_credentials_are_missing 2026-03-31 10:04:26 +00:00
dave cb6ebf1d69 storkit: create 447_bug_element_tab_completion_display_name_breaks_bot_command_matching 2026-03-31 09:58:58 +00:00
Timmy a006985faf Bump version to 0.8.3 2026-03-30 18:17:09 +01:00
dave 3fce9ec082 feat: add Linux arm64 build to release script
Builds aarch64-unknown-linux-musl via cross alongside the existing
x86_64 Linux and macOS arm64 targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:15:16 +00:00
dave 03026c70cc storkit: create 446_story_oauth_login_button_in_web_ui 2026-03-30 16:27:30 +00:00
24 changed files with 556 additions and 79 deletions
+5 -2
View File
@@ -1,5 +1,7 @@
{ {
"enabledMcpjsonServers": ["storkit"], "enabledMcpjsonServers": [
"storkit"
],
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(./server/target/debug/storkit:*)", "Bash(./server/target/debug/storkit:*)",
@@ -67,7 +69,8 @@
"Bash(tail *)", "Bash(tail *)",
"Bash(wc *)", "Bash(wc *)",
"Bash(npx vite:*)", "Bash(npx vite:*)",
"Bash(npm run dev:*)" "Bash(npm run dev:*)",
"Bash(stat *)"
] ]
} }
} }
@@ -0,0 +1,20 @@
---
name: "OAuth login button in web UI"
---
# Story 446: OAuth login button in web UI
## User Story
As a user of the storkit web UI, I want a login button that triggers the Anthropic OAuth flow, so that I can authenticate without manually navigating to /oauth/authorize.
## Acceptance Criteria
- [ ] Web UI shows a login/authenticate button when no OAuth token is active
- [ ] Clicking the button navigates to /oauth/authorize which starts the Anthropic OAuth flow
- [ ] After successful OAuth callback, the UI updates to show the authenticated state
- [ ] If already authenticated, the button is hidden or shows the current auth status
## Out of Scope
- TBD
@@ -0,0 +1,27 @@
---
name: "Element tab-completion display name breaks bot command matching"
---
# Bug 447: Element tab-completion display name breaks bot command matching
## Description
When a user tab-completes a bot mention in Element, the Matrix client inserts the display name (e.g. `timmy ⚡️`) rather than the user ID (`@timmy`). If the display name contains emoji or special characters, the `strip_bot_mention` function in chat::util may fail to match it against the bot name, causing commands like `ambient on` to not be recognized.
## How to Reproduce
1. Set bot display_name to include emoji (e.g. `timmy ⚡️`) in bot.toml\n2. In Element, tab-complete the bot name to get `timmy ⚡️`\n3. Send `timmy ⚡️ ambient on`\n4. The bot does not respond — command not matched
## Actual Result
Bot ignores the command. The display name with emoji doesn't match during strip_bot_mention, so the command text is not correctly extracted.
## Expected Result
Bot should recognize commands regardless of whether the mention was tab-completed with the display name (including emoji) or typed manually as @localpart.
## Acceptance Criteria
- [ ] strip_bot_mention handles display names containing emoji and special characters
- [ ] strip_bot_mention handles Element's tab-completion format (display name followed by colon or comma)
- [ ] Commands work whether the user types @timmy, timmy, or tab-completes timmy ⚡️
@@ -0,0 +1,20 @@
---
name: "Send OAuth login link via chat when credentials are missing"
---
# Story 448: Send OAuth login link via chat when credentials are missing
## User Story
As a storkit user on Matrix or WhatsApp, I want the bot to send me a clickable OAuth authorize link when credentials are missing or expired, so that I can authenticate without terminal access or manually constructing the URL.
## Acceptance Criteria
- [ ] When storkit detects missing or expired credentials during a chat interaction, it sends the user a clickable /oauth/authorize link
- [ ] Works on Matrix and WhatsApp transports
- [ ] After successful OAuth callback, the user can immediately resume chatting without restarting storkit
- [ ] If credentials are already valid, no login link is sent
## Out of Scope
- TBD
Generated
+114 -50
View File
@@ -26,7 +26,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"generic-array", "generic-array",
] ]
@@ -38,7 +38,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher",
"cpufeatures", "cpufeatures 0.2.17",
] ]
[[package]] [[package]]
@@ -265,16 +265,16 @@ checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.8.3" version = "1.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"arrayvec", "arrayvec",
"cc", "cc",
"cfg-if", "cfg-if",
"constant_time_eq", "constant_time_eq",
"cpufeatures", "cpufeatures 0.3.0",
] ]
[[package]] [[package]]
@@ -286,6 +286,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "block-padding" name = "block-padding"
version = "0.3.3" version = "0.3.3"
@@ -391,7 +400,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher",
"cpufeatures", "cpufeatures 0.2.17",
] ]
[[package]] [[package]]
@@ -427,7 +436,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"inout", "inout",
"zeroize", "zeroize",
] ]
@@ -492,6 +501,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "const_panic" name = "const_panic"
version = "0.2.15" version = "0.2.15"
@@ -551,6 +566,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -596,6 +620,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ctr" name = "ctr"
version = "0.9.2" version = "0.9.2"
@@ -612,9 +645,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest", "digest 0.10.7",
"fiat-crypto", "fiat-crypto",
"rustc_version", "rustc_version",
"serde", "serde",
@@ -740,7 +773,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"zeroize", "zeroize",
] ]
@@ -813,11 +846,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -862,7 +906,7 @@ dependencies = [
"ed25519", "ed25519",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"sha2", "sha2 0.10.9",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -1371,7 +1415,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -1451,6 +1495,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.8.1" version = "1.8.1"
@@ -1862,9 +1915,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.92" version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -2158,7 +2211,7 @@ dependencies = [
"serde", "serde",
"serde_html_form", "serde_html_form",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -2251,7 +2304,7 @@ dependencies = [
"ruma", "ruma",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
@@ -2286,7 +2339,7 @@ dependencies = [
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -2340,7 +2393,7 @@ dependencies = [
"rmp-serde", "rmp-serde",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"thiserror 2.0.18", "thiserror 2.0.18",
"zeroize", "zeroize",
] ]
@@ -2599,7 +2652,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"sha2", "sha2 0.10.9",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
] ]
@@ -2657,7 +2710,7 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"hmac", "hmac",
] ]
@@ -2842,7 +2895,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [ dependencies = [
"cpufeatures", "cpufeatures 0.2.17",
"opaque-debug", "opaque-debug",
"universal-hash", "universal-hash",
] ]
@@ -3504,7 +3557,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"ruma-common", "ruma-common",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -3552,7 +3605,7 @@ version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [ dependencies = [
"sha2", "sha2 0.10.9",
"walkdir", "walkdir",
] ]
@@ -3876,8 +3929,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -3887,8 +3940,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.2",
] ]
[[package]] [[package]]
@@ -4019,7 +4083,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "storkit" name = "storkit"
version = "0.8.2" version = "0.8.4"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -4046,7 +4110,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"serde_yaml", "serde_yaml",
"sha2", "sha2 0.11.0",
"strip-ansi-escapes", "strip-ansi-escapes",
"tempfile", "tempfile",
"tokio", "tokio",
@@ -4408,7 +4472,7 @@ dependencies = [
"toml_datetime 1.1.0+spec-1.1.0", "toml_datetime 1.1.0+spec-1.1.0",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow 1.0.0", "winnow 1.0.1",
] ]
[[package]] [[package]]
@@ -4438,7 +4502,7 @@ dependencies = [
"indexmap", "indexmap",
"toml_datetime 1.1.0+spec-1.1.0", "toml_datetime 1.1.0+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.0", "winnow 1.0.1",
] ]
[[package]] [[package]]
@@ -4447,7 +4511,7 @@ version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [ dependencies = [
"winnow 1.0.0", "winnow 1.0.1",
] ]
[[package]] [[package]]
@@ -4681,7 +4745,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@@ -4781,7 +4845,7 @@ dependencies = [
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
"x25519-dalek", "x25519-dalek",
@@ -4851,9 +4915,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.115" version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -4864,9 +4928,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.65" version = "0.4.66"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -4874,9 +4938,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.115" version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -4884,9 +4948,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.115" version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -4897,9 +4961,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.115" version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -4984,9 +5048,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.92" version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5465,9 +5529,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
+1 -1
View File
@@ -21,7 +21,7 @@ rust-embed = "8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
sha2 = "0.10" sha2 = "0.11.0"
serde_yaml = "0.9" serde_yaml = "0.9"
strip-ansi-escapes = "0.2" strip-ansi-escapes = "0.2"
tempfile = "3" tempfile = "3"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.8.2", "version": "0.8.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.8.2", "version": "0.8.4",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"private": true, "private": true,
"version": "0.8.2", "version": "0.8.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -19,6 +19,7 @@ vi.mock("./api/client", () => {
setModelPreference: vi.fn(), setModelPreference: vi.fn(),
cancelChat: vi.fn(), cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(), setAnthropicApiKey: vi.fn(),
getOAuthStatus: vi.fn(),
}; };
class ChatWebSocket { class ChatWebSocket {
connect() {} connect() {}
@@ -65,6 +66,7 @@ describe("App", () => {
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.getOAuthStatus.mockResolvedValue({ authenticated: false, expired: false, expires_at: 0, has_refresh_token: false });
}); });
async function renderApp() { async function renderApp() {
+28 -1
View File
@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import type { OAuthStatus } from "./api/client";
import { api } from "./api/client"; import { api } from "./api/client";
import { Chat } from "./components/Chat"; import { Chat } from "./components/Chat";
import { SelectionScreen } from "./components/selection/SelectionScreen"; import { SelectionScreen } from "./components/selection/SelectionScreen";
@@ -14,6 +15,27 @@ function App() {
const [isOpening, setIsOpening] = React.useState(false); const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]); const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null); const [homeDir, setHomeDir] = React.useState<string | null>(null);
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus | null>(
null,
);
React.useEffect(() => {
let active = true;
function fetchOAuthStatus() {
api
.getOAuthStatus()
.then((s) => {
if (active) setOauthStatus(s);
})
.catch(() => {});
}
fetchOAuthStatus();
const intervalId = window.setInterval(fetchOAuthStatus, 5000);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, []);
React.useEffect(() => { React.useEffect(() => {
api api
@@ -182,10 +204,15 @@ function App() {
onCloseSuggestions={closeSuggestions} onCloseSuggestions={closeSuggestions}
completionError={completionError} completionError={completionError}
currentPartial={currentPartial} currentPartial={currentPartial}
oauthStatus={oauthStatus}
/> />
) : ( ) : (
<div className="workspace" style={{ height: "100%" }}> <div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} /> <Chat
projectPath={projectPath}
onCloseProject={closeProject}
oauthStatus={oauthStatus}
/>
</div> </div>
)} )}
+11
View File
@@ -205,6 +205,13 @@ export interface CommandOutput {
exit_code: number; exit_code: number;
} }
export interface OAuthStatus {
authenticated: boolean;
expired: boolean;
expires_at: number;
has_refresh_token: boolean;
}
declare const __STORKIT_PORT__: string; declare const __STORKIT_PORT__: string;
const DEFAULT_API_BASE = "/api"; const DEFAULT_API_BASE = "/api";
@@ -402,6 +409,10 @@ export const api = {
deleteStory(storyId: string) { deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId }); return callMcpTool("delete_story", { story_id: storyId });
}, },
/** Fetch OAuth status from the server. */
getOAuthStatus() {
return requestJson<OAuthStatus>("/oauth/status", {}, "");
},
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */ /** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) { botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>( return requestJson<{ response: string }>(
+12 -7
View File
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import type { import type {
AnthropicModelInfo, AnthropicModelInfo,
OAuthStatus,
PipelineState, PipelineState,
WizardStateData, WizardStateData,
} from "../api/client"; } from "../api/client";
@@ -164,9 +165,10 @@ const getContextWindowSize = (
interface ChatProps { interface ChatProps {
projectPath: string; projectPath: string;
onCloseProject: () => void; onCloseProject: () => void;
oauthStatus?: OAuthStatus | null;
} }
export function Chat({ projectPath, onCloseProject }: ChatProps) { export function Chat({ projectPath, onCloseProject, oauthStatus = null }: ChatProps) {
const { messages, setMessages, clearMessages } = useChatHistory(projectPath); const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [model, setModel] = useState("claude-code-pty"); const [model, setModel] = useState("claude-code-pty");
@@ -615,12 +617,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const sendMessage = async (messageText: string) => { const sendMessage = async (messageText: string) => {
if (!messageText.trim()) return; if (!messageText.trim()) return;
// /help — show available slash commands overlay
if (/^\/help\s*$/i.test(messageText)) {
setShowHelp(true);
return;
}
// /reset — clear session and message history without LLM // /reset — clear session and message history without LLM
if (/^\/reset\s*$/i.test(messageText)) { if (/^\/reset\s*$/i.test(messageText)) {
setMessages([]); setMessages([]);
@@ -657,6 +653,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
"overview", "overview",
"rebuild", "rebuild",
"loc", "loc",
"help",
"ambient",
"htop",
"rmtree",
"timer",
"unblock",
"unreleased",
"setup",
]); ]);
if (knownCommands.has(cmd)) { if (knownCommands.has(cmd)) {
@@ -940,6 +944,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
enableTools={enableTools} enableTools={enableTools}
onToggleTools={setEnableTools} onToggleTools={setEnableTools}
wsConnected={wsConnected} wsConnected={wsConnected}
oauthStatus={oauthStatus}
/> />
{/* Two-column content area */} {/* Two-column content area */}
+56
View File
@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import type { OAuthStatus } from "../api/client";
import { api } from "../api/client"; import { api } from "../api/client";
const { useState, useEffect } = React; const { useState, useEffect } = React;
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
enableTools: boolean; enableTools: boolean;
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean; wsConnected: boolean;
oauthStatus?: OAuthStatus | null;
} }
const getContextEmoji = (percentage: number): string => { const getContextEmoji = (percentage: number): string => {
@@ -55,6 +57,7 @@ export function ChatHeader({
enableTools, enableTools,
onToggleTools, onToggleTools,
wsConnected, wsConnected,
oauthStatus = null,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
@@ -340,6 +343,59 @@ export function ChatHeader({
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}> <div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
{oauthStatus !== null &&
(!oauthStatus.authenticated || oauthStatus.expired) && (
<button
type="button"
title="Authenticate with Claude via OAuth"
onClick={() => {
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
}}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#1a3a5c",
color: "#7eb8f7",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
whiteSpace: "nowrap",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#234d7a";
e.currentTarget.style.color = "#a8d4ff";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#1a3a5c";
e.currentTarget.style.color = "#7eb8f7";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#234d7a";
e.currentTarget.style.color = "#a8d4ff";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#1a3a5c";
e.currentTarget.style.color = "#7eb8f7";
}}
>
{oauthStatus.expired ? "Re-authenticate" : "Login with Claude"}
</button>
)}
{oauthStatus?.authenticated && !oauthStatus.expired && (
<span
title="Authenticated with Claude via OAuth"
style={{
fontSize: "0.8em",
color: "#4caf50",
whiteSpace: "nowrap",
}}
>
Claude
</span>
)}
<div <div
style={{ style={{
fontSize: "0.75em", fontSize: "0.75em",
@@ -1,4 +1,5 @@
import type { KeyboardEvent } from "react"; import type { KeyboardEvent } from "react";
import type { OAuthStatus } from "../../api/client";
import { ProjectPathInput } from "./ProjectPathInput.tsx"; import { ProjectPathInput } from "./ProjectPathInput.tsx";
import { RecentProjectsList } from "./RecentProjectsList.tsx"; import { RecentProjectsList } from "./RecentProjectsList.tsx";
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
onCloseSuggestions: () => void; onCloseSuggestions: () => void;
completionError: string | null; completionError: string | null;
currentPartial: string; currentPartial: string;
oauthStatus?: OAuthStatus | null;
} }
export function SelectionScreen({ export function SelectionScreen({
@@ -43,6 +45,7 @@ export function SelectionScreen({
onCloseSuggestions, onCloseSuggestions,
completionError, completionError,
currentPartial, currentPartial,
oauthStatus = null,
}: SelectionScreenProps) { }: SelectionScreenProps) {
const resolvedHomeDir = homeDir const resolvedHomeDir = homeDir
? homeDir.endsWith("/") ? homeDir.endsWith("/")
@@ -57,6 +60,37 @@ export function SelectionScreen({
<h1>Storkit</h1> <h1>Storkit</h1>
<p>Paste or complete a project path to start.</p> <p>Paste or complete a project path to start.</p>
{oauthStatus !== null && (
<div style={{ marginBottom: "1rem" }}>
{!oauthStatus.authenticated || oauthStatus.expired ? (
<button
type="button"
onClick={() => {
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
}}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #1a3a5c",
backgroundColor: "#1a3a5c",
color: "#7eb8f7",
cursor: "pointer",
fontSize: "0.9em",
}}
>
{oauthStatus.expired ? "Re-authenticate with Claude" : "Login with Claude"}
</button>
) : (
<span
title="Authenticated with Claude via OAuth"
style={{ color: "#4caf50", fontSize: "0.9em" }}
>
Authenticated with Claude
</span>
)}
</div>
)}
{knownProjects.length > 0 && ( {knownProjects.length > 0 && (
<RecentProjectsList <RecentProjectsList
projects={knownProjects} projects={knownProjects}
+38
View File
@@ -50,10 +50,48 @@ export const SLASH_COMMANDS: SlashCommand[] = [
name: "/overview <number>", name: "/overview <number>",
description: "Show the implementation summary for a merged story.", description: "Show the implementation summary for a merged story.",
}, },
{
name: "/ambient",
description: "Toggle ambient mode: `/ambient on` or `/ambient off`.",
},
{
name: "/htop",
description:
"Show live system and agent process dashboard: `/htop`, `/htop 10m`, `/htop stop`.",
},
{
name: "/loc",
description:
"Show top source files by line count: `/loc` (top 10), `/loc <N>`, or `/loc <filepath>`.",
},
{
name: "/rmtree <number>",
description:
"Delete the worktree for a story without removing it from the pipeline.",
},
{ {
name: "/rebuild", name: "/rebuild",
description: "Rebuild the server binary and restart.", description: "Rebuild the server binary and restart.",
}, },
{
name: "/timer",
description:
"Schedule a deferred agent start: `/timer <story_id> <HH:MM>`, `/timer list`, `/timer cancel <story_id>`.",
},
{
name: "/unblock <number>",
description:
"Reset a blocked story: clears blocked flag and resets retry count.",
},
{
name: "/unreleased",
description: "Show stories merged to master since the last release tag.",
},
{
name: "/setup",
description:
"Show setup wizard progress; or `/setup confirm` / `/setup skip` / `/setup retry` to drive the wizard.",
},
{ {
name: "/reset", name: "/reset",
description: description:
+14
View File
@@ -30,6 +30,20 @@ export default defineConfig(() => {
proxy.on("error", (_err) => {}); proxy.on("error", (_err) => {});
}, },
}, },
"/oauth": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {});
},
},
"/callback": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {});
},
},
}, },
watch: { watch: {
ignored: [ ignored: [
+5 -1
View File
@@ -81,9 +81,12 @@ echo "==> Releasing ${TAG}"
echo "==> Building macOS (native)..." echo "==> Building macOS (native)..."
cargo build --release cargo build --release
echo "==> Building Linux (static musl via cross)..." echo "==> Building Linux amd64 (static musl via cross)..."
cross build --release --target x86_64-unknown-linux-musl cross build --release --target x86_64-unknown-linux-musl
echo "==> Building Linux arm64 (static musl via cross)..."
cross build --release --target aarch64-unknown-linux-musl
# ── Package ──────────────────────────────────────────────────── # ── Package ────────────────────────────────────────────────────
DIST="target/dist" DIST="target/dist"
rm -rf "$DIST" rm -rf "$DIST"
@@ -91,6 +94,7 @@ mkdir -p "$DIST"
cp "target/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-macos-arm64" cp "target/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-macos-arm64"
cp "target/x86_64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-amd64" cp "target/x86_64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-amd64"
cp "target/aarch64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-arm64"
chmod +x "${DIST}"/* chmod +x "${DIST}"/*
echo "==> Binaries:" echo "==> Binaries:"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "storkit" name = "storkit"
version = "0.8.2" version = "0.8.4"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
@@ -616,7 +616,13 @@ pub(super) async fn handle_message(
} }
Err(e) => { Err(e) => {
slog!("[matrix-bot] LLM error: {e}"); slog!("[matrix-bot] LLM error: {e}");
let err_msg = format!("Error processing your request: {e}"); let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. [Click here to log in to Claude]({url})"
)
} else {
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone()); let _ = msg_tx.send(err_msg.clone());
(err_msg, None) (err_msg, None)
} }
@@ -686,6 +692,24 @@ mod tests {
assert_eq!(prompt, "@bob:example.com: What's up?"); assert_eq!(prompt, "@bob:example.com: What's up?");
} }
// -- OAuth login link formatting ----------------------------------------
#[test]
fn oauth_error_produces_login_link() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(msg.contains("[Click here to log in to Claude]"));
}
#[test]
fn non_oauth_error_not_formatted_as_link() {
let err = "Some unrelated error";
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
}
// -- bot_name / system prompt ------------------------------------------- // -- bot_name / system prompt -------------------------------------------
#[test] #[test]
+25 -1
View File
@@ -383,7 +383,13 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
} }
Err(e) => { Err(e) => {
slog!("[whatsapp] LLM error: {e}"); slog!("[whatsapp] LLM error: {e}");
let err_msg = format!("Error processing your request: {e}"); let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!(
"Authentication required. Log in to Claude here: {url}"
)
} else {
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone()); let _ = msg_tx.send(err_msg.clone());
(err_msg, None) (err_msg, None)
} }
@@ -491,6 +497,24 @@ mod tests {
}) })
} }
// ── OAuth login link formatting ───────────────────────────────────────
#[test]
fn whatsapp_oauth_error_produces_plain_text_url() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = crate::llm::oauth::extract_login_url_from_error(err);
assert!(url.is_some(), "should extract URL from OAuth error");
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
}
#[test]
fn whatsapp_non_oauth_error_not_formatted_as_link() {
let err = "Some unrelated error occurred during processing";
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
}
// ── Allowlist tests ─────────────────────────────────────────────────── // ── Allowlist tests ───────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
+49 -5
View File
@@ -46,29 +46,44 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
/// - `@bot_localpart:server.com rest` → `rest` /// - `@bot_localpart:server.com rest` → `rest`
/// - `@bot_localpart rest` → `rest` /// - `@bot_localpart rest` → `rest`
/// - `DisplayName rest` → `rest` /// - `DisplayName rest` → `rest`
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim(); let trimmed = message.trim();
// Try full Matrix user ID (e.g. "@timmy:homeserver.local") // Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest; return strip_mention_separator(rest);
} }
// Try @localpart (e.g. "@timmy") // Try @localpart (e.g. "@timmy")
if let Some(localpart) = bot_user_id.split(':').next() if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart) && let Some(rest) = strip_prefix_ci(trimmed, localpart)
{ {
return rest; return strip_mention_separator(rest);
} }
// Try display name (e.g. "Timmy") // Try display name (e.g. "Timmy" or "timmy ⚡️")
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest; return strip_mention_separator(rest);
} }
trimmed trimmed
} }
/// Strip an optional Element tab-completion separator (`:` or `,`) and
/// surrounding whitespace from the start of text that follows a bot mention.
///
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the
/// name. Without this strip the leading `:` would be treated as part of the
/// command name and no command would match.
fn strip_mention_separator(rest: &str) -> &str {
let rest = rest.trim_start();
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest);
rest.trim_start()
}
/// Returns `true` when `text` ends while inside an open fenced code block. /// Returns `true` when `text` ends while inside an open fenced code block.
/// ///
/// A fenced code block opens and closes on lines that start with ` ``` ` /// A fenced code block opens and closes on lines that start with ` ``` `
@@ -334,7 +349,36 @@ mod tests {
#[test] #[test]
fn strip_mention_comma_after_name() { fn strip_mention_comma_after_name() {
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local"); let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help"); assert_eq!(rest.trim(), "help");
}
#[test]
fn strip_mention_colon_separator_element_tab_completion() {
// Element tab-completes display names with a trailing ": "
let rest = strip_bot_mention(
"timmy ⚡️: ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_emoji_display_name_no_separator() {
// Display name with emoji, no separator
let rest = strip_bot_mention(
"timmy ⚡️ ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_colon_after_localpart() {
// Element may also produce "@timmy: help"
let rest = strip_bot_mention("@timmy: help", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "help");
} }
// -- drain_complete_paragraphs ------------------------------------------ // -- drain_complete_paragraphs ------------------------------------------
+33
View File
@@ -161,10 +161,43 @@ pub async fn refresh_access_token() -> Result<(), String> {
Ok(()) Ok(())
} }
/// Extract the OAuth login URL from an error message produced by the Claude Code provider.
///
/// The provider returns errors like:
/// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"`
///
/// Returns the URL portion when the error indicates missing or expired credentials,
/// `None` otherwise.
pub fn extract_login_url_from_error(err: &str) -> Option<&str> {
let marker = "Please log in: ";
let start = err.find(marker)?;
Some(err[start + marker.len()..].trim())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn extract_login_url_from_oauth_error() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
let url = extract_login_url_from_error(err);
assert_eq!(url, Some("http://localhost:3001/oauth/authorize"));
}
#[test]
fn extract_login_url_returns_none_for_unrelated_error() {
let err = "Some other error occurred";
assert!(extract_login_url_from_error(err).is_none());
}
#[test]
fn extract_login_url_with_different_port() {
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize";
let url = extract_login_url_from_error(err);
assert_eq!(url, Some("http://localhost:3002/oauth/authorize"));
}
#[test] #[test]
fn parse_credentials_file() { fn parse_credentials_file() {
let json = r#"{ let json = r#"{
+4 -2
View File
@@ -138,9 +138,11 @@ impl ClaudeCodeProvider {
on_token("\n*Refreshing authentication token...*\n"); on_token("\n*Refreshing authentication token...*\n");
continue; continue;
} }
Err(e) => { Err(_e) => {
let port = crate::http::resolve_port();
let login_url = format!("http://localhost:{port}/oauth/authorize");
return Err(format!( return Err(format!(
"OAuth session expired. Please run `claude login` to re-authenticate. ({e})" "OAuth session expired or credentials missing. Please log in: {login_url}"
)); ));
} }
} }
+28 -3
View File
@@ -123,7 +123,32 @@ pub async fn rebuild_and_restart(
workspace_root.display() workspace_root.display()
); );
// 3. Build the server binary, matching the current build profile so the // 3. Rebuild the frontend bundle so rust-embed picks up the latest assets.
let frontend_dir = workspace_root.join("frontend");
if frontend_dir.join("package.json").exists() {
slog!("[rebuild] Building frontend");
let fe_output = tokio::task::spawn_blocking({
let frontend_dir = frontend_dir.clone();
move || {
std::process::Command::new("npm")
.args(["run", "build"])
.current_dir(&frontend_dir)
.output()
}
})
.await
.map_err(|e| format!("Frontend build task panicked: {e}"))?
.map_err(|e| format!("Failed to run npm run build: {e}"))?;
if !fe_output.status.success() {
let stderr = String::from_utf8_lossy(&fe_output.stderr);
slog!("[rebuild] Frontend build failed:\n{stderr}");
return Err(format!("Frontend build failed:\n{stderr}"));
}
slog!("[rebuild] Frontend build succeeded");
}
// 4. Build the server binary, matching the current build profile so the
// re-exec via current_exe() picks up the new binary. // re-exec via current_exe() picks up the new binary.
let build_args: Vec<&str> = if cfg!(debug_assertions) { let build_args: Vec<&str> = if cfg!(debug_assertions) {
vec!["build", "-p", "storkit"] vec!["build", "-p", "storkit"]
@@ -152,14 +177,14 @@ pub async fn rebuild_and_restart(
slog!("[rebuild] Build succeeded, re-execing with new binary"); slog!("[rebuild] Build succeeded, re-execing with new binary");
// 4. Send shutdown notification before replacing the process so that chat // 5. Send shutdown notification before replacing the process so that chat
// participants know the bot is going offline. Best-effort only — we // participants know the bot is going offline. Best-effort only — we
// do not abort the rebuild if the send fails. // do not abort the rebuild if the send fails.
if let Some(n) = notifier { if let Some(n) = notifier {
n.notify(ShutdownReason::Rebuild).await; n.notify(ShutdownReason::Rebuild).await;
} }
// 5. Re-exec with the new binary. // 6. Re-exec with the new binary.
// Use the cargo output path rather than current_exe() so that rebuilds // Use the cargo output path rather than current_exe() so that rebuilds
// inside Docker work correctly — the running binary may be installed at // inside Docker work correctly — the running binary may be installed at
// /usr/local/bin/storkit (read-only) while cargo writes the new binary // /usr/local/bin/storkit (read-only) while cargo writes the new binary