diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d483673..d1448ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,8 @@ # v0.4.7: tag-driven release pipeline. # -# Push a `v0.4.x` tag and this workflow builds release binaries -# for Linux (x86_64), macOS (x86_64 + arm64), and Windows -# (x86_64), then uploads them as assets on the corresponding -# GitHub Release. +# Push a `vX.Y.Z` tag and this workflow builds release binaries +# for Linux (x86_64), macOS (x86_64 + arm64), and Windows (x86_64), +# then uploads them as assets on the corresponding GitHub Release. # # The pipeline runs `kimetsu doctor --skip-mcp` on every artifact # before uploading — so a broken build fails the release, not the @@ -11,12 +10,12 @@ # (we don't auto-publish from CI to keep approvals explicit). # # Two artifact sets per platform: -# * `kimetsu---lean` — default Cargo build, -# NoopEmbedder + FTS-only retrieval. Smallest binary, no model -# download on install. -# * `kimetsu---embeddings` — built with -# `--features embeddings`. Larger binary; fastembed-rs + -# ONNX runtime linked statically when possible. +# * `kimetsu---lean` — built with +# `--no-default-features`: NoopEmbedder + FTS-only retrieval. +# Smallest binary, no model download on install. +# * `kimetsu---embeddings` — default build. +# Larger binary; fastembed-rs + ONNX runtime linked statically +# when possible. name: release @@ -42,21 +41,17 @@ jobs: matrix: include: # Linux - - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, flavor: lean, extra_features: "" } - - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, flavor: embeddings, extra_features: "--features embeddings" } + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, flavor: lean, extra_features: "--no-default-features" } + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, flavor: embeddings, extra_features: "" } + # macOS Intel + - { os: macos-15-intel, target: x86_64-apple-darwin, flavor: lean, extra_features: "--no-default-features" } + - { os: macos-15-intel, target: x86_64-apple-darwin, flavor: embeddings, extra_features: "" } # macOS Apple Silicon - # - # v0.4.11: x86_64-apple-darwin (macos-13) runners were - # dropped from the matrix. As of late 2026 those runners - # are deprecated + under-provisioned on GitHub's free - # tier and the jobs queue indefinitely. Apple Silicon - # is the dominant Mac architecture now; users on Intel - # Macs can `cargo install kimetsu-cli` from source. - - { os: macos-14, target: aarch64-apple-darwin, flavor: lean, extra_features: "" } - - { os: macos-14, target: aarch64-apple-darwin, flavor: embeddings, extra_features: "--features embeddings" } + - { os: macos-14, target: aarch64-apple-darwin, flavor: lean, extra_features: "--no-default-features" } + - { os: macos-14, target: aarch64-apple-darwin, flavor: embeddings, extra_features: "" } # Windows - - { os: windows-latest, target: x86_64-pc-windows-msvc, flavor: lean, extra_features: "" } - - { os: windows-latest, target: x86_64-pc-windows-msvc, flavor: embeddings, extra_features: "--features embeddings" } + - { os: windows-latest, target: x86_64-pc-windows-msvc, flavor: lean, extra_features: "--no-default-features" } + - { os: windows-latest, target: x86_64-pc-windows-msvc, flavor: embeddings, extra_features: "" } steps: - uses: actions/checkout@v4 @@ -72,10 +67,12 @@ jobs: - name: build kimetsu-cli run: | - cargo build --release -p kimetsu-cli --target ${{ matrix.target }} ${{ matrix.extra_features }} + cargo build --release --locked -p kimetsu-cli --target ${{ matrix.target }} ${{ matrix.extra_features }} - name: smoke-test built binary (doctor --skip-mcp) shell: bash + env: + KIMETSU_USER_BRAIN: "0" run: | if [ "${{ matrix.flavor }}" = "lean" ]; then BINARY="target/${{ matrix.target }}/release/kimetsu" @@ -85,7 +82,7 @@ jobs: [ "${{ runner.os }}" = "Windows" ] && BINARY="${BINARY}.exe" "$BINARY" --version # doctor expects a workspace; the cloned repo is one. - "$BINARY" doctor --skip-mcp --workspace . || true + "$BINARY" doctor --skip-mcp --workspace . - name: package archive shell: bash @@ -98,7 +95,11 @@ jobs: else cp "target/${{ matrix.target }}/release/kimetsu" "dist/$NAME/" fi - cp LICENSE-MIT LICENSE-APACHE README.md "dist/$NAME/" + MIT_LICENSE="LICENSE-MIT" + APACHE_LICENSE="LICENSE-APACHE" + [ -f "$MIT_LICENSE" ] || MIT_LICENSE="docs/LICENSE-MIT" + [ -f "$APACHE_LICENSE" ] || APACHE_LICENSE="docs/LICENSE-APACHE" + cp "$MIT_LICENSE" "$APACHE_LICENSE" README.md "dist/$NAME/" cd dist if [ "${{ runner.os }}" = "Windows" ]; then powershell -Command "Compress-Archive -Path $NAME -DestinationPath $NAME.zip" @@ -164,23 +165,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-crates: - # v0.4.9: serial cargo publish to crates.io after the binary + # Serial cargo publish to crates.io after the binary # matrix + GH Release succeed. Gated on: # (a) tag push (handled by `if`) # (b) the `release` job succeeding (handled by `needs`) # (c) CARGO_REGISTRY_TOKEN secret being set on the repo # - # v0.4.10: kimetsu-harbor-rs is intentionally NOT published. - # It's marked `publish = false` in its Cargo.toml because: - # * `kimetsu-cli` (the user-facing binary that powers - # `cargo install kimetsu-cli`) doesn't depend on it. - # * It's a Terminal-Bench operator tool, still iterating - # internally; publishing implies backward-compat we don't - # want to commit to yet. - # The `kimetsu-harbor-agent` binary still ships in every - # GH Release archive (built by the lean matrix above), so - # benchmark users can grab it from there. - # # Crate publish order matches the user-facing dep DAG: # kimetsu-core (no kimetsu deps) # kimetsu-brain (needs core) @@ -266,5 +256,7 @@ jobs: echo "Users can now install with:" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "cargo install kimetsu-cli" >> $GITHUB_STEP_SUMMARY - echo "cargo install kimetsu-cli --features embeddings" >> $GITHUB_STEP_SUMMARY + echo "cargo install kimetsu-cli --no-default-features # lean build" >> $GITHUB_STEP_SUMMARY + echo "kimetsu update --check" >> $GITHUB_STEP_SUMMARY + echo "kimetsu uninstall --dry-run" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/Cargo.lock b/Cargo.lock index aa5f552..087bc14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,7 +1483,7 @@ dependencies = [ [[package]] name = "kimetsu-agent" -version = "0.7.2" +version = "0.7.3" dependencies = [ "blake3", "kimetsu-brain", @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "kimetsu-brain" -version = "0.7.2" +version = "0.7.3" dependencies = [ "blake3", "fastembed", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "kimetsu-chat" -version = "0.7.2" +version = "0.7.3" dependencies = [ "base64 0.22.1", "crossterm", @@ -1530,13 +1530,14 @@ dependencies = [ [[package]] name = "kimetsu-cli" -version = "0.7.2" +version = "0.7.3" dependencies = [ "clap", "kimetsu-agent", "kimetsu-brain", "kimetsu-chat", "kimetsu-core", + "reqwest", "serde", "serde_json", "tracing", @@ -1545,7 +1546,7 @@ dependencies = [ [[package]] name = "kimetsu-core" -version = "0.7.2" +version = "0.7.3" dependencies = [ "serde", "serde_json", @@ -1556,7 +1557,7 @@ dependencies = [ [[package]] name = "kimetsu-e2e" -version = "0.7.2" +version = "0.7.3" dependencies = [ "kimetsu-agent", "kimetsu-brain", diff --git a/Cargo.toml b/Cargo.toml index a9aea47..fa125ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ resolver = "3" # everything below except the per-crate `description`, `keywords`, # and `categories` which live in each `[package]` block since # crates.io enforces them per crate. -version = "0.7.2" +version = "0.7.3" edition = "2024" # Rust ecosystem dual-license (matches tokio, serde, fastembed-rs, # etc.). UNLICENSED would block crates.io entirely. diff --git a/README.md b/README.md index b68c7f7..bcb165d 100644 --- a/README.md +++ b/README.md @@ -81,21 +81,22 @@ detection? See **[docs/HOW-KIMETSU-WORKS.md](docs/HOW-KIMETSU-WORKS.md)**. Kimetsu is a single Rust binary. Pick your flavor: ```bash -# Lean — fast lexical (FTS) retrieval, no model download (~30s build) +# Default — semantic search enabled (fastembed + ONNX; first run downloads BGE-small) cargo install kimetsu-cli -# With local semantic search — pulls fastembed + ONNX, first run -# downloads BGE-small (~67 MB). Cosine retrieval + conflict detection light up. -cargo install kimetsu-cli --features embeddings +# Lean — fast lexical (FTS) retrieval, no model download +cargo install kimetsu-cli --no-default-features # From source -cargo install --path crates/kimetsu-cli # add --features embeddings if you like +cargo install --path crates/kimetsu-cli # add --no-default-features for lean ``` Prefer not to touch the Rust toolchain? Pre-built binaries for **Linux / macOS / Windows** ship on every -[GitHub Release](https://github.com/RodCor/kimetsu/releases) — drop one in -`~/.local/bin`. +[GitHub Release](https://github.com/RodCor/kimetsu/releases). Extract the archive and put +`kimetsu` / `kimetsu.exe` somewhere on `PATH` (`~/.local/bin`, `/usr/local/bin`, +or `%USERPROFILE%\.cargo\bin`). macOS prebuilt archives are published for both +Intel and Apple Silicon. Confirm it's healthy: @@ -104,6 +105,23 @@ kimetsu --version kimetsu doctor # checks paths, brain.db, embedder, MCP, bridge ``` +Check for updates: + +```bash +kimetsu update --check +kimetsu update # updates discovered kimetsu binaries on PATH/current install +kimetsu uninstall --dry-run +kimetsu uninstall --yes # removes discovered kimetsu binaries +``` + +`kimetsu update` downloads the matching GitHub Release archive for your +platform and flavor, then updates the current executable plus verified +`kimetsu` copies in known install locations such as Cargo bin, `~/.local/bin`, +`/usr/local/bin`, or `%USERPROFILE%\.cargo\bin`. It does not scan the whole +disk. `kimetsu uninstall` removes those same verified binaries; it leaves +project `.kimetsu/` directories and the user brain intact unless you explicitly +pass `--delete-user-data`. + **Prerequisites:** Rust 1.85+ (stable) and a Claude or OpenAI credential (`CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`). That's it for chat — Docker, Harbor, and Python are only needed for benchmark runs. @@ -154,8 +172,7 @@ kimetsu brain memory top # most useful memories so far | **MCP sidecar** | `kimetsu mcp serve` exposes the brain to any MCP host as ~18 tools. | Built as a small Rust workspace (`kimetsu-cli`, `-chat`, `-agent`, `-brain`, -`-core`, plus a benchmark-only Harbor adapter). Lint + tests run clean on every -change. +and `-core`). Lint + tests run clean on every change. --- @@ -171,6 +188,5 @@ change. ## License -Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE) — your -choice. The same dual license used across the Rust ecosystem (tokio, serde, -fastembed-rs). +Dual-licensed under [MIT](docs/LICENSE-MIT) or [Apache-2.0](docs/LICENSE-APACHE) — your +choice. diff --git a/crates/kimetsu-agent/Cargo.toml b/crates/kimetsu-agent/Cargo.toml index edfb7c6..c5a3fd3 100644 --- a/crates/kimetsu-agent/Cargo.toml +++ b/crates/kimetsu-agent/Cargo.toml @@ -23,8 +23,8 @@ embeddings = ["kimetsu-brain/embeddings"] [dependencies] blake3.workspace = true -kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.2" } -kimetsu-core = { path = "../kimetsu-core", version = "0.7.2" } +kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.3" } +kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" } regex.workspace = true reqwest.workspace = true rusqlite.workspace = true diff --git a/crates/kimetsu-brain/Cargo.toml b/crates/kimetsu-brain/Cargo.toml index 9dd7ba3..096ba49 100644 --- a/crates/kimetsu-brain/Cargo.toml +++ b/crates/kimetsu-brain/Cargo.toml @@ -31,7 +31,7 @@ blake3.workspace = true # supports BGE-M3 + Jina-v2-base-code alongside BGE-small. fastembed = { version = "5", optional = true } ignore.workspace = true -kimetsu-core = { path = "../kimetsu-core", version = "0.7.2" } +kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" } # v0.4.5: regex backs the secret-redaction patterns in # `kimetsu_brain::redact`. The crate is already a workspace pin # elsewhere; we just opt this crate into it now. diff --git a/crates/kimetsu-chat/Cargo.toml b/crates/kimetsu-chat/Cargo.toml index d98422f..8d90620 100644 --- a/crates/kimetsu-chat/Cargo.toml +++ b/crates/kimetsu-chat/Cargo.toml @@ -36,9 +36,9 @@ embeddings = ["kimetsu-brain/embeddings"] # surface, not a benchmark harness. [dependencies] -kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.2" } -kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.2" } -kimetsu-core = { path = "../kimetsu-core", version = "0.7.2" } +kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.3" } +kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.3" } +kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" } base64.workspace = true crossterm.workspace = true reqwest.workspace = true diff --git a/crates/kimetsu-cli/Cargo.toml b/crates/kimetsu-cli/Cargo.toml index c43c9b3..8aa6944 100644 --- a/crates/kimetsu-cli/Cargo.toml +++ b/crates/kimetsu-cli/Cargo.toml @@ -38,12 +38,13 @@ path = "src/main.rs" [dependencies] clap.workspace = true -kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.2" } -kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.2" } -kimetsu-chat = { path = "../kimetsu-chat", version = "0.7.2" } -kimetsu-core = { path = "../kimetsu-core", version = "0.7.2" } +kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.3" } +kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.3" } +kimetsu-chat = { path = "../kimetsu-chat", version = "0.7.3" } +kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" } # v0.4.6: `kimetsu doctor` serializes its report struct so --json # output can be piped into CI / hooks. +reqwest.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/kimetsu-cli/src/doctor.rs b/crates/kimetsu-cli/src/doctor.rs index b12f87f..6c04666 100644 --- a/crates/kimetsu-cli/src/doctor.rs +++ b/crates/kimetsu-cli/src/doctor.rs @@ -385,7 +385,7 @@ fn check_embedder_default() -> CheckReport { name: "default embedder loads", category: "retrieval", outcome: Outcome::Warn { - reason: "no `embeddings` feature — semantic retrieval off. Reinstall with `cargo install kimetsu-cli --features embeddings` for cosine blend.".into(), + reason: "no `embeddings` feature - semantic retrieval off. Reinstall with `cargo install kimetsu-cli` for the default semantic build.".into(), }, detail: Some("NoopEmbedder (FTS-only retrieval)".into()), } @@ -428,37 +428,36 @@ fn check_mcp_tools_advertised(_workspace: &Path, skip: bool) -> CheckReport { } fn check_hooks_installed(workspace: &Path) -> CheckReport { - // Soft check: see whether `.claude/hooks/pre-turn*` or - // `.codex/hooks/pre-turn*` is present. If neither is, suggest - // `kimetsu plugin install`. Not having hooks isn't a failure — - // most users will run the chat REPL directly, not call - // kimetsu_brain_context through Claude Code's pre-turn hook. - let candidates = [ - ".claude/hooks/pre-turn.sh", - ".claude/hooks/pre-turn.ps1", - ".codex/hooks/pre-turn.sh", - ".codex/hooks/pre-turn.ps1", - ".kimetsu/hooks/pre-turn.sh", - ".kimetsu/hooks/pre-turn.ps1", - ]; let mut found = Vec::new(); - for rel in candidates { - if workspace.join(rel).exists() { - found.push(rel); - } + + let claude_settings = workspace.join(".claude").join("settings.json"); + if file_contains_all( + &claude_settings, + &["UserPromptSubmit", "kimetsu brain context-hook"], + ) { + found.push(".claude/settings.json"); } + + let codex_hooks = workspace.join(".codex").join("hooks.json"); + if file_contains_all( + &codex_hooks, + &["UserPromptSubmit", "kimetsu brain context-hook"], + ) { + found.push(".codex/hooks.json"); + } + if found.is_empty() { CheckReport { - name: "pre-turn hook installed", + name: "host brain hook installed", category: "plugin", outcome: Outcome::Skip { - reason: "no .claude/.codex/.kimetsu hooks — run `kimetsu plugin install claude` (or codex) to enable pre-turn brain injection".into(), + reason: "no Claude/Codex brain hook config found - run `kimetsu plugin install claude` or `kimetsu plugin install codex` to enable prompt-time brain injection".into(), }, detail: None, } } else { CheckReport { - name: "pre-turn hook installed", + name: "host brain hook installed", category: "plugin", outcome: Outcome::Pass, detail: Some(format!("{} hook(s): {}", found.len(), found.join(", "))), @@ -466,6 +465,13 @@ fn check_hooks_installed(workspace: &Path) -> CheckReport { } } +fn file_contains_all(path: &Path, needles: &[&str]) -> bool { + let Ok(text) = std::fs::read_to_string(path) else { + return false; + }; + needles.iter().all(|needle| text.contains(needle)) +} + fn embeddings_feature_enabled() -> bool { // The `embeddings` feature on kimetsu-brain is the only way // open_default_embedder returns a non-Noop embedder. We can't @@ -573,4 +579,62 @@ mod tests { assert_ne!(warn, skip); assert_ne!(fail, skip); } + + #[test] + fn doctor_detects_current_codex_hooks_json() { + let tmp = tempdir_in_test("kimetsu-doctor-codex-hooks"); + let codex_dir = tmp.join(".codex"); + std::fs::create_dir_all(&codex_dir).expect("mkdir"); + std::fs::write( + codex_dir.join("hooks.json"), + r#"{ + "hooks": { + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": "kimetsu brain context-hook --workspace ." + }] + }] + } + }"#, + ) + .expect("write hooks"); + + let report = check_hooks_installed(&tmp); + assert!(matches!(report.outcome, Outcome::Pass), "{report:?}"); + assert!( + report + .detail + .as_deref() + .unwrap_or_default() + .contains(".codex/hooks.json") + ); + let _ = std::fs::remove_dir_all(tmp); + } + + #[test] + fn doctor_rejects_legacy_pre_turn_scripts() { + let tmp = tempdir_in_test("kimetsu-doctor-legacy-hooks"); + let legacy_dir = tmp.join(".codex").join("hooks"); + std::fs::create_dir_all(&legacy_dir).expect("mkdir"); + std::fs::write( + legacy_dir.join("pre-turn.ps1"), + "kimetsu brain context-hook --workspace .", + ) + .expect("write legacy hook"); + + let report = check_hooks_installed(&tmp); + assert!(matches!(report.outcome, Outcome::Skip { .. }), "{report:?}"); + let _ = std::fs::remove_dir_all(tmp); + } + + fn tempdir_in_test(prefix: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let tmp = std::env::temp_dir().join(format!("{prefix}-{nanos}")); + std::fs::create_dir_all(&tmp).expect("mkdir"); + tmp + } } diff --git a/crates/kimetsu-cli/src/main.rs b/crates/kimetsu-cli/src/main.rs index 5d7df96..e7a16c6 100644 --- a/crates/kimetsu-cli/src/main.rs +++ b/crates/kimetsu-cli/src/main.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; mod doctor; +mod update; use clap::{Args, Parser, Subcommand}; use kimetsu_agent::bench::{BenchOptions, run_benchmark}; @@ -77,6 +78,11 @@ enum Command { /// `KIMETSU_BRAIN_EMBEDDER`, or whenever something looks /// off — doctor surfaces the actionable fix. Doctor(DoctorArgs), + /// Check GitHub Releases for a newer Kimetsu and update discovered + /// local installs. + Update(UpdateArgs), + /// Remove discovered Kimetsu executables from this machine. + Uninstall(UninstallArgs), } #[derive(Debug, Args)] @@ -93,6 +99,36 @@ struct DoctorArgs { skip_mcp: bool, } +#[derive(Debug, Args)] +struct UpdateArgs { + /// Only check whether a newer release exists; do not install it. + #[arg(long)] + check: bool, + /// Print the installs that would be updated without writing files. + #[arg(long)] + dry_run: bool, + /// Reinstall even when the latest release is the current version. + #[arg(long)] + force: bool, + /// Release flavor to install. `auto` preserves this binary's build flavor. + #[arg(long, default_value = "auto")] + flavor: String, +} + +#[derive(Debug, Args)] +struct UninstallArgs { + /// Print the installs that would be removed without deleting anything. + #[arg(long)] + dry_run: bool, + /// Confirm removal. Required unless --dry-run is used. + #[arg(long)] + yes: bool, + /// Also remove the user Kimetsu brain directory (~/.kimetsu or + /// KIMETSU_USER_BRAIN_DIR). Project .kimetsu directories are never removed. + #[arg(long)] + delete_user_data: bool, +} + #[derive(Debug, Args)] struct ChatArgs { /// Workspace root the agent operates inside. All shell / file tools @@ -678,9 +714,29 @@ fn run() -> KimetsuResult<()> { Command::Plugin { command } => plugin(command), Command::Chat(args) => chat(args), Command::Doctor(args) => doctor_cmd(args), + Command::Update(args) => update_cmd(args), + Command::Uninstall(args) => uninstall_cmd(args), } } +fn update_cmd(args: UpdateArgs) -> KimetsuResult<()> { + let flavor = update::UpdateFlavor::parse(&args.flavor)?; + update::run(update::UpdateOptions { + check: args.check, + dry_run: args.dry_run, + force: args.force, + flavor, + }) +} + +fn uninstall_cmd(args: UninstallArgs) -> KimetsuResult<()> { + update::uninstall(update::UninstallOptions { + dry_run: args.dry_run, + yes: args.yes, + delete_user_data: args.delete_user_data, + }) +} + /// v0.4.6: `kimetsu doctor` entry point. Runs the full health /// suite + prints either the human or JSON report. /// @@ -1357,9 +1413,10 @@ fn brain_status(json: bool) -> KimetsuResult<()> { Ok(()) } -/// v0.6: `kimetsu brain context-hook` — Claude Code UserPromptSubmit hook. +/// v0.6: `kimetsu brain context-hook` — UserPromptSubmit hook. /// Reads `{"prompt":"..."}` JSON from stdin, retrieves relevant capsules, -/// prints them to stdout for injection. Silent (exit 0) when brain has nothing. +/// prints Codex/Claude-compatible hook JSON to stdout for injection. +/// Silent (exit 0) when brain has nothing. fn brain_context_hook(args: ContextHookArgs) -> KimetsuResult<()> { use kimetsu_brain::context::ContextRequest; use std::io::Read; @@ -1408,7 +1465,7 @@ fn brain_context_hook(args: ContextHookArgs) -> KimetsuResult<()> { return Ok(()); // Nothing relevant — zero output } - println!("[Kimetsu brain] Relevant knowledge for this task:"); + let mut additional_context = String::from("Kimetsu brain relevant knowledge for this task:"); for capsule in &bundle.capsules { // Strip the "scope:kind - " prefix from the summary for readability let text = capsule @@ -1416,12 +1473,30 @@ fn brain_context_hook(args: ContextHookArgs) -> KimetsuResult<()> { .splitn(3, " - ") .nth(1) .unwrap_or(&capsule.summary); - println!("{}", text); + additional_context.push('\n'); + additional_context.push_str(text); } + print_user_prompt_submit_context(&additional_context)?; Ok(()) } +fn print_user_prompt_submit_context(additional_context: &str) -> KimetsuResult<()> { + let output = user_prompt_submit_context_output(additional_context); + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} + +fn user_prompt_submit_context_output(additional_context: &str) -> serde_json::Value { + serde_json::json!({ + "continue": true, + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": additional_context, + }, + }) +} + /// v0.7: Claude Code Stop hook. Reads session JSON from stdin, counts /// kimetsu_brain_record calls in the transcript, and prints a summary /// banner. Silent exit when the session was short or nothing to report. @@ -2272,6 +2347,23 @@ mod tests { use std::fs; use std::io::Cursor; + #[test] + fn context_hook_output_is_user_prompt_submit_json() { + let value = user_prompt_submit_context_output("Kimetsu context"); + assert_eq!(value["continue"], true); + assert_eq!( + value["hookSpecificOutput"]["hookEventName"], + "UserPromptSubmit" + ); + assert_eq!( + value["hookSpecificOutput"]["additionalContext"], + "Kimetsu context" + ); + + let text = serde_json::to_string(&value).expect("json"); + assert!(text.starts_with('{'), "{text}"); + } + /// MP-5b: end-to-end driver test for the interactive loop. Inject three /// pending proposals, script `a\nr\nbecause noisy\ns\n` as stdin input, /// confirm: one proposal becomes a memory, one is rejected with the diff --git a/crates/kimetsu-cli/src/update.rs b/crates/kimetsu-cli/src/update.rs new file mode 100644 index 0000000..415890d --- /dev/null +++ b/crates/kimetsu-cli/src/update.rs @@ -0,0 +1,755 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use kimetsu_core::KimetsuResult; +use reqwest::blocking::Client; +use serde::Deserialize; + +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/RodCor/kimetsu/releases/latest"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateFlavor { + Auto, + Lean, + Embeddings, +} + +impl UpdateFlavor { + pub fn parse(value: &str) -> KimetsuResult { + match value { + "auto" => Ok(Self::Auto), + "lean" => Ok(Self::Lean), + "embeddings" | "semantic" => Ok(Self::Embeddings), + other => Err( + format!("unknown update flavor `{other}`; use auto, lean, or embeddings").into(), + ), + } + } + + fn resolve(self) -> &'static str { + match self { + Self::Auto => default_flavor(), + Self::Lean => "lean", + Self::Embeddings => "embeddings", + } + } +} + +#[derive(Debug, Clone)] +pub struct UpdateOptions { + pub check: bool, + pub dry_run: bool, + pub force: bool, + pub flavor: UpdateFlavor, +} + +#[derive(Debug, Clone)] +pub struct UninstallOptions { + pub dry_run: bool, + pub yes: bool, + pub delete_user_data: bool, +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +impl GitHubRelease { + fn version(&self) -> &str { + self.tag_name.trim_start_matches('v') + } +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Clone)] +struct Installation { + path: PathBuf, + source: InstallSource, + version: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallSource { + CurrentExe, + Path, + CargoBin, + StandardBin, +} + +impl InstallSource { + fn label(self) -> &'static str { + match self { + Self::CurrentExe => "current executable", + Self::Path => "PATH", + Self::CargoBin => "Cargo bin", + Self::StandardBin => "standard bin", + } + } +} + +pub fn run(options: UpdateOptions) -> KimetsuResult<()> { + let flavor = options.flavor.resolve(); + let release = fetch_latest_release()?; + let latest = release.version(); + let current = Version::parse(CURRENT_VERSION) + .ok_or_else(|| format!("current kimetsu version is not semver-like: {CURRENT_VERSION}"))?; + let latest_parsed = Version::parse(latest).ok_or_else(|| { + format!( + "latest release tag is not semver-like: {}", + release.tag_name + ) + })?; + let target = release_target().ok_or_else(|| { + format!( + "no prebuilt Kimetsu release target for {} {}; install with `cargo install kimetsu-cli --force`", + env::consts::OS, + env::consts::ARCH + ) + })?; + let asset = select_asset(&release.assets, target, flavor).ok_or_else(|| { + format!( + "latest release {} has no `{target}` `{flavor}` asset; see {}", + release.tag_name, release.html_url + ) + })?; + + println!("current: kimetsu {CURRENT_VERSION}"); + println!("latest: kimetsu {latest}"); + println!("target: {target} ({flavor})"); + + if latest_parsed < current && !options.force { + println!("status: local build is newer than the latest published release"); + return Ok(()); + } + + if latest_parsed == current && !options.force { + println!("status: up to date"); + return Ok(()); + } + + println!("status: update available"); + println!("release: {}", release.html_url); + + let installs = discover_installations(); + if installs.is_empty() { + println!("found: no installed kimetsu executables in known locations"); + } else { + println!("found: {} kimetsu executable(s)", installs.len()); + for install in &installs { + let version = install.version.as_deref().unwrap_or("unknown"); + println!( + " {} [{}] {version}", + install.path.display(), + install.source.label() + ); + } + } + + if options.check { + println!( + "next: run `kimetsu update` to install {}", + release.tag_name + ); + return Ok(()); + } + + if installs.is_empty() { + return Err("no Kimetsu executable found to update".into()); + } + + if options.dry_run { + println!("dry-run: would download {}", asset.name); + for install in installs { + println!("dry-run: would update {}", install.path.display()); + } + return Ok(()); + } + + let workdir = make_temp_dir("kimetsu-update")?; + let archive_path = workdir.join(&asset.name); + download_asset(&asset.browser_download_url, &archive_path)?; + let new_binary = extract_binary(&archive_path, &workdir.join("extract"))?; + + let mut updated = 0usize; + let mut failed = Vec::new(); + for install in installs { + match replace_installation(&new_binary, &install.path) { + Ok(ReplaceOutcome::Updated) => { + updated += 1; + println!("updated: {}", install.path.display()); + } + Ok(ReplaceOutcome::Scheduled) => { + updated += 1; + println!( + "scheduled: {} (replacement completes after this process exits)", + install.path.display() + ); + } + Err(err) => { + println!("failed: {} ({err})", install.path.display()); + failed.push(install.path); + } + } + } + + let _ = fs::remove_dir_all(&workdir); + + if failed.is_empty() { + println!("done: updated {updated} Kimetsu executable(s)"); + Ok(()) + } else { + Err(format!( + "updated {updated} Kimetsu executable(s), but {} location(s) failed; rerun from an elevated shell if needed", + failed.len() + ) + .into()) + } +} + +pub fn uninstall(options: UninstallOptions) -> KimetsuResult<()> { + let installs = discover_installations(); + if installs.is_empty() { + println!("found: no installed kimetsu executables in known locations"); + } else { + println!("found: {} kimetsu executable(s)", installs.len()); + for install in &installs { + let version = install.version.as_deref().unwrap_or("unknown"); + println!( + " {} [{}] {version}", + install.path.display(), + install.source.label() + ); + } + } + + if let Some(user_data) = user_data_dir() + && options.delete_user_data + { + println!("user-data: {}", user_data.display()); + } + + if options.dry_run { + for install in installs { + println!("dry-run: would remove {}", install.path.display()); + } + if let Some(user_data) = user_data_dir() + && options.delete_user_data + { + println!("dry-run: would remove user data {}", user_data.display()); + } + return Ok(()); + } + + if !options.yes { + return Err( + "refusing to uninstall without confirmation; rerun with `kimetsu uninstall --yes`" + .into(), + ); + } + + let mut removed = 0usize; + let mut failed = Vec::new(); + for install in installs { + match remove_installation(&install.path) { + Ok(RemoveOutcome::Removed) => { + removed += 1; + println!("removed: {}", install.path.display()); + } + Ok(RemoveOutcome::Scheduled) => { + removed += 1; + println!( + "scheduled: {} (removal completes after this process exits)", + install.path.display() + ); + } + Err(err) => { + println!("failed: {} ({err})", install.path.display()); + failed.push(install.path); + } + } + } + + if options.delete_user_data + && let Some(user_data) = user_data_dir() + && user_data.exists() + { + match fs::remove_dir_all(&user_data) { + Ok(()) => println!("removed user data: {}", user_data.display()), + Err(err) => { + println!("failed user data: {} ({err})", user_data.display()); + failed.push(user_data); + } + } + } + + if failed.is_empty() { + println!("done: removed {removed} Kimetsu executable(s)"); + Ok(()) + } else { + Err(format!( + "removed {removed} Kimetsu executable(s), but {} location(s) failed; rerun from an elevated shell if needed", + failed.len() + ) + .into()) + } +} + +fn fetch_latest_release() -> KimetsuResult { + let client = Client::builder().timeout(Duration::from_secs(20)).build()?; + let response = client + .get(LATEST_RELEASE_URL) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", format!("kimetsu/{CURRENT_VERSION}")) + .send()? + .error_for_status()?; + Ok(response.json()?) +} + +fn download_asset(url: &str, target: &Path) -> KimetsuResult<()> { + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + let mut response = client + .get(url) + .header("Accept", "application/octet-stream") + .header("User-Agent", format!("kimetsu/{CURRENT_VERSION}")) + .send()? + .error_for_status()?; + let mut file = fs::File::create(target)?; + io::copy(&mut response, &mut file)?; + Ok(()) +} + +fn extract_binary(archive: &Path, dest: &Path) -> KimetsuResult { + fs::create_dir_all(dest)?; + if archive.extension().and_then(|s| s.to_str()) == Some("zip") { + extract_zip(archive, dest)?; + } else { + extract_tar_gz(archive, dest)?; + } + find_binary_under(dest).ok_or_else(|| { + format!( + "archive {} did not contain {}", + archive.display(), + binary_name() + ) + .into() + }) +} + +fn extract_zip(archive: &Path, dest: &Path) -> KimetsuResult<()> { + let archive = ps_quote(archive); + let dest = ps_quote(dest); + let script = format!("Expand-Archive -LiteralPath {archive} -DestinationPath {dest} -Force"); + let status = ProcessCommand::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &script, + ]) + .status()?; + if status.success() { + Ok(()) + } else { + Err("failed to extract release zip with PowerShell Expand-Archive".into()) + } +} + +fn extract_tar_gz(archive: &Path, dest: &Path) -> KimetsuResult<()> { + let status = ProcessCommand::new("tar") + .arg("-xzf") + .arg(archive) + .arg("-C") + .arg(dest) + .status()?; + if status.success() { + Ok(()) + } else { + Err("failed to extract release tarball with tar".into()) + } +} + +fn discover_installations() -> Vec { + let mut candidates = BTreeMap::::new(); + if let Ok(current) = env::current_exe() { + insert_candidate(&mut candidates, current, InstallSource::CurrentExe); + } + for path in path_candidates() { + insert_candidate(&mut candidates, path, InstallSource::Path); + } + for path in cargo_bin_candidates() { + insert_candidate(&mut candidates, path, InstallSource::CargoBin); + } + for path in standard_bin_candidates() { + insert_candidate(&mut candidates, path, InstallSource::StandardBin); + } + + candidates + .into_iter() + .filter_map(|(path, source)| { + if !path.exists() { + return None; + } + let version = kimetsu_version_at(&path)?; + Some(Installation { + path, + source, + version: Some(version), + }) + }) + .collect() +} + +fn insert_candidate( + candidates: &mut BTreeMap, + path: PathBuf, + source: InstallSource, +) { + let key = path.canonicalize().unwrap_or(path); + candidates.entry(key).or_insert(source); +} + +fn path_candidates() -> Vec { + let Some(path_var) = env::var_os("PATH") else { + return Vec::new(); + }; + env::split_paths(&path_var) + .map(|dir| dir.join(binary_name())) + .collect() +} + +fn cargo_bin_candidates() -> Vec { + let mut out = Vec::new(); + if let Some(cargo_home) = env::var_os("CARGO_HOME") { + out.push(PathBuf::from(cargo_home).join("bin").join(binary_name())); + } + if let Some(home) = home_dir() { + out.push(home.join(".cargo").join("bin").join(binary_name())); + } + out +} + +fn standard_bin_candidates() -> Vec { + let mut out = Vec::new(); + if cfg!(windows) { + if let Some(profile) = env::var_os("USERPROFILE") { + out.push( + PathBuf::from(profile) + .join(".cargo") + .join("bin") + .join(binary_name()), + ); + } + } else { + if let Some(home) = home_dir() { + out.push(home.join(".local").join("bin").join(binary_name())); + } + out.push(PathBuf::from("/usr/local/bin").join(binary_name())); + out.push(PathBuf::from("/opt/homebrew/bin").join(binary_name())); + } + out +} + +fn home_dir() -> Option { + if cfg!(windows) { + env::var_os("USERPROFILE").map(PathBuf::from) + } else { + env::var_os("HOME").map(PathBuf::from) + } +} + +fn user_data_dir() -> Option { + if let Some(dir) = env::var_os("KIMETSU_USER_BRAIN_DIR") { + return Some(PathBuf::from(dir)); + } + home_dir().map(|home| home.join(".kimetsu")) +} + +fn kimetsu_version_at(path: &Path) -> Option { + let output = ProcessCommand::new(path).arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let text = stdout.trim(); + if !text.starts_with("kimetsu ") { + return None; + } + Some(text.trim_start_matches("kimetsu ").to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReplaceOutcome { + Updated, + Scheduled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoveOutcome { + Removed, + Scheduled, +} + +fn replace_installation(source: &Path, target: &Path) -> KimetsuResult { + #[cfg(windows)] + { + if is_current_exe(target) { + return schedule_windows_self_replace(source, target); + } + fs::copy(source, target)?; + Ok(ReplaceOutcome::Updated) + } + #[cfg(not(windows))] + { + atomic_replace(source, target)?; + Ok(ReplaceOutcome::Updated) + } +} + +fn remove_installation(target: &Path) -> KimetsuResult { + #[cfg(windows)] + { + if is_current_exe(target) { + return schedule_windows_self_delete(target); + } + } + fs::remove_file(target)?; + Ok(RemoveOutcome::Removed) +} + +#[cfg(not(windows))] +fn atomic_replace(source: &Path, target: &Path) -> KimetsuResult<()> { + let tmp = target.with_file_name(format!( + "{}.kimetsu-update-{}", + target + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("kimetsu"), + std::process::id() + )); + fs::copy(source, &tmp)?; + mark_executable(&tmp)?; + fs::rename(&tmp, target)?; + Ok(()) +} + +#[cfg(windows)] +fn schedule_windows_self_replace(source: &Path, target: &Path) -> KimetsuResult { + let staged = target.with_file_name(format!( + "{}.kimetsu-update-{}", + target + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("kimetsu.exe"), + std::process::id() + )); + fs::copy(source, &staged)?; + let script = format!( + "$pidToWait = {pid}; \ + $src = {src}; \ + $dst = {dst}; \ + while (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue) {{ Start-Sleep -Milliseconds 200 }}; \ + Move-Item -LiteralPath $src -Destination $dst -Force", + pid = std::process::id(), + src = ps_quote(&staged), + dst = ps_quote(target), + ); + ProcessCommand::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-Command", + &script, + ]) + .spawn()?; + Ok(ReplaceOutcome::Scheduled) +} + +#[cfg(windows)] +fn schedule_windows_self_delete(target: &Path) -> KimetsuResult { + let script = format!( + "$pidToWait = {pid}; \ + $dst = {dst}; \ + while (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue) {{ Start-Sleep -Milliseconds 200 }}; \ + Remove-Item -LiteralPath $dst -Force", + pid = std::process::id(), + dst = ps_quote(target), + ); + ProcessCommand::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-Command", + &script, + ]) + .spawn()?; + Ok(RemoveOutcome::Scheduled) +} + +#[cfg(windows)] +fn is_current_exe(path: &Path) -> bool { + let Ok(current) = env::current_exe().and_then(|p| p.canonicalize()) else { + return false; + }; + path.canonicalize() + .map(|target| target == current) + .unwrap_or(false) +} + +#[cfg(unix)] +fn mark_executable(path: &Path) -> KimetsuResult<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +#[cfg(all(not(unix), not(windows)))] +fn mark_executable(_path: &Path) -> KimetsuResult<()> { + Ok(()) +} + +fn find_binary_under(root: &Path) -> Option { + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let entries = fs::read_dir(path).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.file_name().and_then(|s| s.to_str()) == Some(binary_name()) { + return Some(path); + } + } + } + None +} + +fn make_temp_dir(prefix: &str) -> KimetsuResult { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn select_asset<'a>( + assets: &'a [GitHubAsset], + target: &str, + flavor: &str, +) -> Option<&'a GitHubAsset> { + assets.iter().find(|asset| { + asset.name.contains(target) + && asset.name.contains(flavor) + && (asset.name.ends_with(".tar.gz") || asset.name.ends_with(".zip")) + }) +} + +fn default_flavor() -> &'static str { + if cfg!(feature = "embeddings") { + "embeddings" + } else { + "lean" + } +} + +fn release_target() -> Option<&'static str> { + match (env::consts::OS, env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +fn binary_name() -> &'static str { + if cfg!(windows) { + "kimetsu.exe" + } else { + "kimetsu" + } +} + +fn ps_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Version { + major: u64, + minor: u64, + patch: u64, +} + +impl Version { + fn parse(value: &str) -> Option { + let clean = value.trim().trim_start_matches('v'); + let numeric = clean.split_once('-').map(|(head, _)| head).unwrap_or(clean); + let mut parts = numeric.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some(Self { + major, + minor, + patch, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compare_handles_multi_digit_minor() { + assert!(Version::parse("0.10.0") > Version::parse("0.9.9")); + assert!(Version::parse("v1.2.3") > Version::parse("1.2.2")); + } + + #[test] + fn select_asset_matches_target_and_flavor() { + let assets = vec![ + GitHubAsset { + name: "kimetsu-0.7.3-x86_64-pc-windows-msvc-lean.zip".into(), + browser_download_url: "https://example.invalid/lean.zip".into(), + }, + GitHubAsset { + name: "kimetsu-0.7.3-x86_64-pc-windows-msvc-embeddings.zip".into(), + browser_download_url: "https://example.invalid/embeddings.zip".into(), + }, + ]; + + let asset = select_asset(&assets, "x86_64-pc-windows-msvc", "embeddings").expect("asset"); + assert_eq!( + asset.name, + "kimetsu-0.7.3-x86_64-pc-windows-msvc-embeddings.zip" + ); + } +} diff --git a/crates/kimetsu-cli/tests/cli_smoke.rs b/crates/kimetsu-cli/tests/cli_smoke.rs index b9e87f1..db3fd47 100644 --- a/crates/kimetsu-cli/tests/cli_smoke.rs +++ b/crates/kimetsu-cli/tests/cli_smoke.rs @@ -49,7 +49,16 @@ fn kimetsu_help_lists_top_level_subcommands() { let stdout = String::from_utf8_lossy(&output.stdout); // Spot-check the key user-facing subcommands. // If one of these disappears, --help drift caught at PR time. - for expected in ["init", "brain", "chat", "doctor", "bridge", "mcp"] { + for expected in [ + "init", + "brain", + "chat", + "doctor", + "bridge", + "mcp", + "update", + "uninstall", + ] { assert!( stdout.contains(expected), "kimetsu --help should mention `{expected}`; got: {stdout}" @@ -57,6 +66,40 @@ fn kimetsu_help_lists_top_level_subcommands() { } } +#[test] +fn kimetsu_uninstall_help_lists_confirmation_flags() { + let output = Command::new(kimetsu_bin()) + .args(["uninstall", "--help"]) + .output() + .expect("spawn kimetsu uninstall --help"); + assert!(output.status.success(), "uninstall --help should exit 0"); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in ["--yes", "--dry-run", "--delete-user-data"] { + assert!( + stdout.contains(expected), + "uninstall --help should mention `{expected}`; got: {stdout}" + ); + } +} + +#[test] +fn kimetsu_update_help_lists_check_mode() { + let output = Command::new(kimetsu_bin()) + .args(["update", "--help"]) + .output() + .expect("spawn kimetsu update --help"); + assert!(output.status.success(), "update --help should exit 0"); + + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in ["--check", "--dry-run", "--flavor"] { + assert!( + stdout.contains(expected), + "update --help should mention `{expected}`; got: {stdout}" + ); + } +} + #[test] fn kimetsu_brain_help_lists_brain_subcommands() { let output = Command::new(kimetsu_bin()) diff --git a/crates/kimetsu-e2e/Cargo.toml b/crates/kimetsu-e2e/Cargo.toml index 8fd6e3c..d54a90f 100644 --- a/crates/kimetsu-e2e/Cargo.toml +++ b/crates/kimetsu-e2e/Cargo.toml @@ -20,9 +20,9 @@ publish = false # Real (non-dev) deps so the test fixtures + scripted provider can be # re-exported from `kimetsu_e2e::prelude` to the integration tests in # `tests/`. Integration tests treat this crate as a normal library. -kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.2" } -kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.2" } -kimetsu-core = { path = "../kimetsu-core", version = "0.7.2" } +kimetsu-agent = { path = "../kimetsu-agent", version = "0.7.3" } +kimetsu-brain = { path = "../kimetsu-brain", version = "0.7.3" } +kimetsu-core = { path = "../kimetsu-core", version = "0.7.3" } rusqlite.workspace = true serde_json.workspace = true time.workspace = true diff --git a/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to docs/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to docs/CONTRIBUTING.md diff --git a/LICENSE-APACHE b/docs/LICENSE-APACHE similarity index 100% rename from LICENSE-APACHE rename to docs/LICENSE-APACHE diff --git a/LICENSE-MIT b/docs/LICENSE-MIT similarity index 100% rename from LICENSE-MIT rename to docs/LICENSE-MIT