diff --git a/.kiro/generators/aws-test.kdl b/.kiro/generators/aws-test.kdl index 3fe8b73..0382618 100644 --- a/.kiro/generators/aws-test.kdl +++ b/.kiro/generators/aws-test.kdl @@ -1,11 +1,9 @@ description "all the AWS tools you want" prompt "you are an AWS expert" -allowed-tools "@awsdocs" - +allowed-tools "@awsdocs" resource "file://AGENTS.md" resource "file://README.md" resource "file://.amazonq/rules/**/*.md" - hook { agent-spawn "whoami" { command "aws sts get-caller-identity" @@ -13,23 +11,22 @@ hook { cache-ttl-seconds 300 } } - native-tool { - aws { - allow "ec2" "s3" - deny "iam" - } + aws { + allows "ec2" "s3" + denies "iam" + } } - mcp "awsbilling" { - command "uvx" - args "awslabs.billing-cost-management-mcp-server@latest" - env "FASTMCP_LOG_LEVEL" "ERROR" + command "uvx" + args """ + awslabs.billing-cost-management-mcp-server@latest + """ + env "FASTMCP_LOG_LEVEL" "ERROR" } - mcp "awsdocs" { - command "uvx" - args "awslabs.aws-documentation-mcp-server@latest" - env "FASTMCP_LOG_LEVEL" "ERROR" - env "AWS_DOCUMENTATION_PARTITION" "aws" + command "uvx" + args "awslabs.aws-documentation-mcp-server@latest" + env "FASTMCP_LOG_LEVEL" "ERROR" + env "AWS_DOCUMENTATION_PARTITION" "aws" } diff --git a/.kiro/generators/bad.kdl b/.kiro/generators/bad.kdl index 5635c74..d8a53e0 100644 --- a/.kiro/generators/bad.kdl +++ b/.kiro/generators/bad.kdl @@ -1 +1,2 @@ -foo "bar" +agent "bad" include-mcp-json="bar" { +} diff --git a/.kiro/generators/base.kdl b/.kiro/generators/base.kdl index 0aa0b7c..d86c3ac 100644 --- a/.kiro/generators/base.kdl +++ b/.kiro/generators/base.kdl @@ -1,39 +1,51 @@ description "Default agent for Kiro" tools "*" -allowed-tools "read" "knowledge" "@fetch" +allowed-tools "read" "knowledge" "fetch" resource "file://README.md" resource "file://.amazonq/rules/**/*.md" alias "execute_shell" "bash" - hook { - agent-spawn "echo" { - command "echo My name is Bob" - timeout-ms 4000 - cache-ttl-seconds 300 - } + agent-spawn echo { + command "echo My name is Bob" + timeout-ms 4000 + cache-ttl-seconds 300 + } } - native-tool { - read { deny ".*Cargo.toml.*" ".*yarn.lock.*"; } - write { deny ".*Cargo.toml.*"; } - shell { - allow "git status" "git fetch" "git diff .*" \ - "git pull .*" "yarn .*" "pulumi preview .*" \ - "kubectl .*" "pdf2.*" "ps .*" "timeout.*" "pgrep.*" - - deny "git commit .*" "git push .*" "kubectl .*delete*" \ - ".*delete.*" "pulumi up.*" "^rm .*" \ - ".*destroy.*" ".*rollout.*" ".*kill.*" - } + read { + denies ".*Cargo.toml.*" ".*yarn.lock.*" + } + write { + denies ".*Cargo.toml.*" + } + shell { + allows "git status" \ + "git fetch" \ + "git diff .*" \ + "git pull.*" \ + "yarn.*" \ + "pulumi preview .*" \ + "pdf2.*" \ + "ps .*" \ + "timeout.*" \ + "pgrep.*" + denies "git commit .*" \ + "git push .*" \ + "kubectl .*delete*" \ + ".*delete.*" \ + "pulumi up.*" \ + "^rm .*" \ + ".*destroy.*" \ + ".*rollout.*" \ + ".*kill.*" + } } - -mcp "rustdocs" { +mcp rustdocs { command "mcp-docsrs" timeout 1200 } - -mcp "cargo" { - command "cargo-mcp" - args "--debug" - timeout 120000 +mcp cargo { + command "cargo-mcp" + args "--debug" + timeout 120000 } diff --git a/.kiro/generators/dependabot.kdl b/.kiro/generators/dependabot.kdl index 0b3c77d..cada853 100644 --- a/.kiro/generators/dependabot.kdl +++ b/.kiro/generators/dependabot.kdl @@ -1,8 +1,12 @@ description "I make life painful for developers" - -native-tool { - shell { override "git commit .*"; override "git push .*"; } - read { override ".*Cargo.toml.*"; } - write { override ".*Cargo.toml.*"; } - aws disable-auto-readonly=true +native-tool disable-auto-readonly=#true { + shell { + overrides "git commit .*" "git push .*" + } + read { + overrides ".*Cargo.toml.*" + } + write { + overrides ".*Cargo.toml.*" + } } diff --git a/.kiro/generators/kg.kdl b/.kiro/generators/kg.kdl index f245153..bbaa55c 100644 --- a/.kiro/generators/kg.kdl +++ b/.kiro/generators/kg.kdl @@ -1,4 +1,4 @@ -agent "base" template=true {} +agent "base" template=#true {} agent "aws-test" { inherits "base" hook { diff --git a/.kiro/global/aws-test.kdl b/.kiro/global/aws-test.kdl index 6931cb9..839d564 100644 --- a/.kiro/global/aws-test.kdl +++ b/.kiro/global/aws-test.kdl @@ -1,15 +1,15 @@ prompt "you are NOT an AWS expert" -allowed-tools "@awsdocs" +allowed-tools "@awsdocs" resource "file://AGENTS.md" resource "file://README.md" native-tool { - aws { - allow "ec2" "s3" - } + aws { + allows "ec2" "s3" + } } mcp "awsdocs" { - command "blah" - args "awslabs.aws-documentation-mcp-server@latest" - env "FASTMCP_LOG_LEVEL" "INFO" - env "AWS_DOCUMENTATION_PARTITION" "ec2" + command "blah" + args "awslabs.aws-documentation-mcp-server@latest" + env "FASTMCP_LOG_LEVEL" "INFO" + env "AWS_DOCUMENTATION_PARTITION" "ec2" } diff --git a/.kiro/global/kg.kdl b/.kiro/global/kg.kdl index d3a668f..56975c7 100644 --- a/.kiro/global/kg.kdl +++ b/.kiro/global/kg.kdl @@ -1,3 +1,3 @@ -agent "base" template=true {} +agent "base" template=#true {} agent "aws-test" { inherits "base"; } agent "dependabot" { } diff --git a/AGENTS.md b/AGENTS.md index 6778b77..c2d3c19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,180 +86,10 @@ When analyzing the code, explicitly check: - Explanation of *why* each change aligns with Unix conventions - Prioritize changes: critical issues first, nice-to-haves last -## Error Handling with color_eyre -This project uses [color_eyre](https://docs.rs/color-eyre/latest/color_eyre/) for enhanced error reporting and diagnostics. +## Error Handling with miette -### Setup & Integration - -**Initialization:** -- Call `color_eyre::install()?` early in `main()` before any fallible operations -- Consider using `color_eyre::config::HookBuilder` for custom configuration -- Disable colors in non-TTY environments or when `NO_COLOR` is set - -**Example:** -```rust -fn main() -> color_eyre::Result<()> { - color_eyre::config::HookBuilder::default() - .display_env_section(false) // Hide environment vars in production - .install()?; - - // Your CLI logic here -} -``` - -### Error Context Best Practices - -**Use `.wrap_err()` and `.wrap_err_with()` liberally:** -- Add context at each layer where it's meaningful -- Focus on *what* the code was trying to do, not *why* it failed (eyre handles that) -- Provide user-actionable information when possible - -**Good context:** -```rust -fs::read_to_string(&config_path) - .wrap_err_with(|| format!("Failed to read config file at {}", config_path.display())) - .wrap_err("Unable to load application configuration")?; -``` - -**Poor context:** -```rust -fs::read_to_string(&config_path) - .wrap_err("Error reading file")?; // Too vague -``` - -### Context Patterns - -**File operations:** -```rust -.wrap_err_with(|| format!("Failed to read '{}'", path.display())) -.wrap_err_with(|| format!("Failed to write to '{}'", path.display())) -.wrap_err_with(|| format!("Failed to create directory '{}'", path.display())) -``` - -**Network/external operations:** -```rust -.wrap_err_with(|| format!("Failed to fetch data from {}", url)) -.wrap_err("Unable to connect to remote service") -``` - -**Parsing/validation:** -```rust -.wrap_err_with(|| format!("Invalid configuration in '{}'", config_path.display())) -.wrap_err_with(|| format!("Failed to parse {} as JSON", file_name)) -``` - -**User input:** -```rust -.wrap_err("Invalid argument provided") -.wrap_err_with(|| format!("Unknown format '{}'. Valid formats: json, yaml, csv", format)) -``` - -### Error Reporting Guidelines - -**For production CLIs:** -- Set `RUST_BACKTRACE=0` behavior as default -- Only show full backtraces in debug builds or with `--verbose` -- Consider using `HookBuilder` to customize what's displayed: -```rust - color_eyre::config::HookBuilder::default() - .display_env_section(cfg!(debug_assertions)) - .issue_url(concat!(env!("CARGO_PKG_REPOSITORY"), "/issues/new")) - .install()?; -``` - -**Error message structure:** -- Top-level context should explain the high-level operation that failed -- Each `.wrap_err()` adds a layer showing the call stack conceptually -- The root cause (from the original error) appears at the bottom -- Suggestions and help text should go in the outermost context - -**User-facing vs. developer errors:** -- Configuration errors, invalid input → Detailed, actionable messages -- Unexpected errors, bugs → Include issue tracker URL via `HookBuilder::issue_url()` -- Network timeouts, connection failures → Suggest retry, check connectivity - -### Common Pitfalls to Avoid - -❌ **Don't add redundant context:** -```rust -// The error already says "No such file or directory" -.wrap_err("File not found")? -``` - -❌ **Don't expose internal implementation details:** -```rust -// User doesn't care about your HashMap -.wrap_err("Failed to insert into cache HashMap")? -``` - -❌ **Don't use context for control flow:** -```rust -// Use proper error types for expected failures -match x { - Some(v) => v, - None => return Err(eyre!("Value missing"))?, // ❌ Not an exceptional error -} -``` - -✅ **Do create custom error types for domain errors:** -```rust -#[derive(Debug, thiserror::Error)] -enum ConfigError { - #[error("Missing required field: {0}")] - MissingField(String), - #[error("Invalid port number: {0}")] - InvalidPort(u16), -} - -// Then wrap with color_eyre for context -validate_config(&config) - .wrap_err("Configuration validation failed")?; -``` - -### Integration with Clap - -**Handling clap errors:** -```rust -use clap::Parser; - -#[derive(Parser)] -struct Cli { /* ... */ } - -fn main() -> color_eyre::Result<()> { - color_eyre::install()?; - - // Clap handles its own error formatting, which is good - let cli = Cli::parse(); - - run(cli)?; - Ok(()) -} -``` - -**Adding suggestions to errors:** -```rust -use color_eyre::{eyre::eyre, Help, SectionExt}; - -if !config_path.exists() { - return Err(eyre!("Config file not found")) - .with_suggestion(|| format!("Create a config file at: {}", config_path.display())) - .suggestion("Run with --init to create a default configuration"); -} -``` - -### Review Checklist - -When reviewing error handling: - -1. Is `color_eyre::install()` called early in `main()`? -2. Does each `.wrap_err()` add meaningful context? -3. Are error messages actionable for the user? -4. Are internal implementation details hidden from user-facing errors? -5. Do errors include suggestions where appropriate? -6. Is backtrace/env output appropriate for the audience (debug vs. production)? -7. Are file paths, URLs, and identifiers included in error context? -8. Do network/IO errors guide users toward resolution? +Refer to [miette](https://docs.rs/miette/latest/miette/) for error handles and reporting. Suggest utilizing [Diagnostic](https://docs.rs/miette/latest/miette/trait.Diagnostic.html) trait or enchanced error reporting ## Performance @@ -267,7 +97,7 @@ When reviewing error handling: Runtime Performance is **NOT** critical or important. This CLI will rarely be executed, it is far **MORE** important that the code is clean, maintainable and **SIMPLE** at the cost of performance. -## Further Documentation +## Further Documentation The directory `docs` contains mdbook formatted documentation for the project. Some notiable files: diff --git a/Cargo.lock b/Cargo.lock index 8392a31..67089a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,83 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arborium" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a0fe8162cd3c0c0a6917d3547a99cf64fb3419754edc3490e61dbcb725d931" +dependencies = [ + "arborium-highlight", + "arborium-json", + "arborium-rust", + "arborium-theme", + "arborium-tree-sitter", + "dlmalloc", +] + +[[package]] +name = "arborium-highlight" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b21b7db1ac1b9b05203cc109a2c547c8ea205e9fd25bcf4f7d59541dcd56d7a" +dependencies = [ + "arborium-theme", + "arborium-tree-sitter", + "streaming-iterator", +] + +[[package]] +name = "arborium-json" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af5484f35d971c21b37c644f19149531cf1d56f6be453744c6ce2bbc3a6d7df" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-rust" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3679029fbf1dfc742eee00c6e7cabfa58636fe33209a3d3356c48fbb18f739a7" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-sysroot" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c7c907513f0fcf989c9d8945fe4d5efd9031243a19789db3f62bf735ec8179" +dependencies = [ + "cc", + "dlmalloc", +] + +[[package]] +name = "arborium-theme" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3800aeb6bb75bb6cb9471c9b845e0a29d58f8a18890f1f248c1c6954d9ea5d8e" + +[[package]] +name = "arborium-tree-sitter" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97dc3525c71cf41e976529b3904da514fb1ec469acb9710b5cf2be5979a2a57" +dependencies = [ + "arborium-sysroot", + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -135,7 +212,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -174,12 +251,6 @@ dependencies = [ "backtrace", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -215,9 +286,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecount" @@ -233,9 +304,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.49" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "shlex", @@ -253,20 +324,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chumsky" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -274,9 +336,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -292,10 +354,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -304,34 +366,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors 4.2.3", - "tracing-error", - "url", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors 4.2.3", - "tracing-core", - "tracing-error", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -344,14 +378,14 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "console" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", @@ -434,7 +468,18 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", +] + +[[package]] +name = "dlmalloc" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6738d2e996274e499bc7b0d693c858b7720b9cd2543a0643a3087e6cb0a4fa16" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -493,7 +538,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -513,13 +558,149 @@ dependencies = [ ] [[package]] -name = "eyre" -version = "0.6.12" +name = "facet" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "ac3244604535a5d0a7c79403f3c6a6184b49be89be99f33c4e8177aebca0167b" dependencies = [ - "indenter", - "once_cell", + "autocfg", + "facet-core", + "facet-macros", + "facet-reflect", +] + +[[package]] +name = "facet-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6337f704e6988f9cfee38f5b2c9001e84c498eb70cb420f6dc0e14ca7c1f85eb" +dependencies = [ + "autocfg", + "impls", +] + +[[package]] +name = "facet-format" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332a9d31f60d8439a68589331f49e5f058937a9e17d067217f3c87621490753d" +dependencies = [ + "facet-core", + "facet-path", + "facet-reflect", + "facet-singularize", + "facet-solver", + "miette", +] + +[[package]] +name = "facet-kdl" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a3459c7f26a83a45199b73d48f2708fa482b5d50bee30bd88a8f90f0765322b" +dependencies = [ + "facet", + "facet-core", + "facet-format", + "facet-path", + "facet-reflect", + "kdl", + "miette", +] + +[[package]] +name = "facet-macro-parse" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b6c80c8dd3925eb08d246ffa6c7c9ccb2f1bc2a9dbe0cccea6c33a0f62cf66" +dependencies = [ + "facet-macro-types", + "proc-macro2", + "quote", +] + +[[package]] +name = "facet-macro-types" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6cacc78f28f5508765bc9625859ca34551bb62e3b9f9e172d325b13490b77" +dependencies = [ + "proc-macro2", + "quote", + "unsynn", +] + +[[package]] +name = "facet-macros" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a4aed1abc3de7cccbf7a162ae64b4aa3d3d1f0a5136c140f83c87b934a9775" +dependencies = [ + "facet-macros-impl", +] + +[[package]] +name = "facet-macros-impl" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e1e176fdc8615d7a023e1b1c4c05db5cb1dc5764add5ca1a5efc8f8b2ccb1d" +dependencies = [ + "facet-macro-parse", + "facet-macro-types", + "proc-macro2", + "quote", + "strsim", + "unsynn", +] + +[[package]] +name = "facet-path" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd02b5b9608066f2417238aae3181a18a3b16b698aca7b448a2ec31ad0dba15c" +dependencies = [ + "arborium", + "facet-core", + "facet-pretty", + "miette", + "miette-arborium", +] + +[[package]] +name = "facet-pretty" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627fb7758b0f1973507d696f2844c28a84e1be6fc75dd6c734c05b33e07382f6" +dependencies = [ + "facet-core", + "facet-reflect", + "owo-colors", +] + +[[package]] +name = "facet-reflect" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8571d80d292b6a256d2c26a6e85dbb4bc7adad7768457c5157b1d9ab044ee24f" +dependencies = [ + "facet-core", + "miette", +] + +[[package]] +name = "facet-singularize" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f7d2db11f545897d8cabedd06b233055784dbb74b1e07259a325dec19b3979" + +[[package]] +name = "facet-solver" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e7f3e9d1169c905d07e354cd1d8e4621945f1c1e55881332d30791781c0e52" +dependencies = [ + "facet-core", + "facet-reflect", ] [[package]] @@ -541,9 +722,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "fluent-uri" @@ -658,7 +839,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -724,9 +905,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -771,27 +952,12 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "http" version = "1.4.0" @@ -891,7 +1057,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -1014,21 +1180,30 @@ dependencies = [ ] [[package]] -name = "indenter" -version = "0.3.4" +name = "impls" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1037,25 +1212,14 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is_ci" version = "1.2.0" @@ -1070,9 +1234,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" @@ -1114,19 +1278,31 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow", +] + [[package]] name = "kiro-generator" version = "0.1.1-rc.6" dependencies = [ "clap", - "color-eyre", "colored", "dirs", "emojis-rs", "enum-iterator", + "facet", + "facet-kdl", "futures", + "indoc", "jsonschema", - "knuffel", "miette", "nix", "serde", @@ -1135,39 +1311,13 @@ dependencies = [ "super-table", "tempfile", "test-log", + "thiserror 2.0.17", "tokio", "tracing", "tracing-error", "tracing-subscriber", ] -[[package]] -name = "knuffel" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04bee6ddc6071011314b1ce4f7705fef6c009401dba4fd22cb0009db6a177413" -dependencies = [ - "base64 0.21.7", - "chumsky", - "knuffel-derive", - "miette", - "thiserror 1.0.69", - "unicode-width 0.1.14", -] - -[[package]] -name = "knuffel-derive" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91977f56c49cfb961e3d840e2e7c6e4a56bde7283898cf606861f1421348283d" -dependencies = [ - "heck 0.4.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1176,15 +1326,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", @@ -1240,34 +1390,45 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "miette" -version = "5.10.0" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "backtrace", "backtrace-ext", - "is-terminal", + "cfg-if", "miette-derive", - "once_cell", - "owo-colors 3.5.0", + "owo-colors", "supports-color", "supports-hyperlinks", "supports-unicode", "terminal_size", "textwrap", - "thiserror 1.0.69", "unicode-width 0.1.14", ] +[[package]] +name = "miette-arborium" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b12314e8eee2a5b4ba79bef82123bacc5c656e8b89e456780ab7c710cf3148" +dependencies = [ + "arborium", + "arborium-highlight", + "arborium-theme", + "miette", + "owo-colors", +] + [[package]] name = "miette-derive" -version = "5.10.0" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1302,6 +1463,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + [[package]] name = "native-tls" version = "0.2.14" @@ -1473,7 +1640,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1506,12 +1673,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owo-colors" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" - [[package]] name = "owo-colors" version = "4.2.3" @@ -1574,44 +1735,20 @@ dependencies = [ "zerovec", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1659,7 +1796,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1710,11 +1847,11 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-channel", @@ -1770,11 +1907,17 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", @@ -1785,9 +1928,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", "rustls-pki-types", @@ -1798,9 +1941,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "zeroize", ] @@ -1824,9 +1967,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "schannel" @@ -1893,20 +2036,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1959,12 +2102,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - [[package]] name = "socket2" version = "0.6.1" @@ -1981,6 +2118,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -2008,48 +2151,30 @@ dependencies = [ [[package]] name = "supports-color" -version = "2.1.0" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" dependencies = [ - "is-terminal", "is_ci", ] [[package]] name = "supports-hyperlinks" -version = "2.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" -dependencies = [ - "is-terminal", -] +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" [[package]] name = "supports-unicode" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" -dependencies = [ - "is-terminal", -] - -[[package]] -name = "syn" -version = "1.0.109" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2073,7 +2198,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2099,9 +2224,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -2112,12 +2237,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.1.17" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "libc", - "winapi", + "rustix", + "windows-sys 0.60.2", ] [[package]] @@ -2138,18 +2263,17 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "textwrap" -version = "0.15.2" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ - "smawk", "unicode-linebreak", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -2178,7 +2302,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2189,7 +2313,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2213,9 +2337,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -2234,7 +2358,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2259,9 +2383,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2317,9 +2441,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2334,14 +2458,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2386,6 +2510,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter-language" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" + [[package]] name = "try-lock" version = "0.2.5" @@ -2394,9 +2524,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-general-category" @@ -2434,6 +2564,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2442,9 +2583,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2577,7 +2718,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "wasm-bindgen-shared", ] @@ -2666,6 +2807,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -2813,6 +2963,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -2855,28 +3014,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2896,7 +3055,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -2936,5 +3095,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/Cargo.toml b/Cargo.toml index bb0a947..bdfbe57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,14 +3,13 @@ name = "kiro-generator" version = "0.1.1-rc.6" edition = "2024" description = "Kiro Agent CLI configuration management" -repository = "https://github.com/CarteraMesh/q-generator" -license = "MIT" -authors = ["gh@cartera-mesh.com"] documentation = "https://docs.rs/q-generator" +readme = "README.md" homepage = "https://github.com/CarteraMesh/q-generator" -keywords = ["kiro", "cli", "agents", "AI"] +repository = "https://github.com/CarteraMesh/q-generator" +license = "MIT" +keywords = ["AI", "agents", "cli", "kiro"] categories = ["command-line-interface", "config"] -readme = "README.md" [package.metadata.deb] maintainer = "Dougefresh " @@ -18,7 +17,7 @@ section = "utility" priority = "optional" assets = [ ["target/release/kg", "usr/bin/", "755"], - { source = "README.md", dest = "usr/share/doc/kiro-generator/README", mode = "644"}, + { source = "README.md", dest = "usr/share/doc/kiro-generator/README", mode = "644" }, ] [[bin]] @@ -27,21 +26,31 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["cargo", "derive", "env", "unicode"] } -color-eyre = { version = "0.6", features = ["issue-url"] } colored = "3.0.0" dirs = "6" -# emojis = "0.8.0" emojis-rs = "0.1.3" enum-iterator = "2.3.0" +# facet = { git = "https://github.com/facet-rs/facet.git", features = [ +# "auto-traits", +# "reflect", +# "simd" +# ] } +facet = { version = "0.42.0", features = ["auto-traits", "reflect", "simd"] } +facet-kdl = { version = "0.42.0" } +# facet-kdl = { git = "https://github.com/facet-rs/facet.git" } futures = "0.3" -jsonschema = { version = "0.37", default-features = false, features = ["resolve-async", "resolve-file"] } -knuffel = "3.2.0" -miette = { version = "5", features = ["fancy"] } +indoc = "2.0.7" +jsonschema = { version = "0.37", default-features = false, features = [ + "resolve-async", + "resolve-file" +] } +miette = { version = "7", features = ["fancy"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml2 = "0.1.3" super-table = { version = "1", features = ["custom_styling"] } tempfile = "3" +thiserror = "2.0.17" tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } tracing = { version = "0.1" } tracing-error = "0.2.1" @@ -52,7 +61,7 @@ nix = { version = "0.30.1", features = ["user"] } test-log = { version = "0.2", default-features = false, features = ["trace"] } [profile.release] -strip = false # Keep symbols for better backtraces -debug = 1 # Line numbers only (smaller than full debug=2) -lto = "thin" # Faster builds than "fat", still good optimization -codegen-units = 16 # Default, balances compile time vs optimization +debug = 1 # Line numbers only (smaller than full debug=2) +strip = false # Keep symbols for better backtraces +lto = "thin" # Faster builds than "fat", still good optimization +codegen-units = 16 # Default, balances compile time vs optimization diff --git a/src/agent/custom_tool.rs b/src/agent/custom_tool.rs index 6796dd7..3fc81f6 100644 --- a/src/agent/custom_tool.rs +++ b/src/agent/custom_tool.rs @@ -3,40 +3,15 @@ use { std::collections::HashMap, }; -#[derive(Clone, Default, Serialize, Deserialize, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum TransportType { - /// Standard input/output transport (default) - #[default] - Stdio, - /// HTTP transport for web-based communication - Http, -} - -#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct OAuthConfig { - /// Custom redirect URI for OAuth flow (e.g., "127.0.0.1:7778") - /// If not specified, a random available port will be assigned by the OS - #[serde(skip_serializing_if = "Option::is_none")] - pub redirect_uri: Option, -} - #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CustomToolConfig { - /// The transport type to use for communication with the MCP server - #[serde(default)] - pub r#type: TransportType, /// The URL for HTTP-based MCP server communication #[serde(default, skip_serializing_if = "String::is_empty")] pub url: String, /// HTTP headers to include when communicating with HTTP-based MCP servers #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: HashMap, - /// OAuth configuration for this server - #[serde(skip_serializing_if = "Option::is_none")] - pub oauth: Option, /// The command string used to initialize the mcp server #[serde(default)] pub command: String, @@ -63,18 +38,15 @@ mod tests { use super::*; #[test] - fn transport_type_default() { + fn tool_defaultttl() { assert_eq!(tool_default_timeout(), 120 * 1000); - assert_eq!(TransportType::default(), TransportType::Stdio); } #[test] fn custom_tool_config_serde() { let config = CustomToolConfig { - r#type: TransportType::Http, url: "http://test".into(), headers: HashMap::new(), - oauth: None, command: "cmd".into(), args: vec!["arg1".into()], env: HashMap::new(), @@ -85,14 +57,4 @@ mod tests { let deserialized: CustomToolConfig = serde_json::from_str(&json).unwrap(); assert_eq!(config, deserialized); } - - #[test] - fn oauth_config_serde() { - let oauth = OAuthConfig { - redirect_uri: Some("localhost:8080".into()), - }; - let json = serde_json::to_string(&oauth).unwrap(); - let deserialized: OAuthConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(oauth, deserialized); - } } diff --git a/src/agent/hook.rs b/src/agent/hook.rs index 886495b..e9d6527 100644 --- a/src/agent/hook.rs +++ b/src/agent/hook.rs @@ -4,7 +4,7 @@ use { }; const DEFAULT_TIMEOUT_MS: u64 = 30_000; -const DEFAULT_MAX_OUTPUT_SIZE: usize = 1024 * 10; +const DEFAULT_MAX_OUTPUT_SIZE: u64 = 1024 * 10; const DEFAULT_CACHE_TTL_SECONDS: u64 = 0; #[derive( @@ -47,7 +47,7 @@ pub struct Hook { /// Max output size of the hook before it is truncated #[serde(default = "Hook::default_max_output_size")] - pub max_output_size: usize, + pub max_output_size: u64, /// How long the hook output is cached before it will be executed again #[serde(default = "Hook::default_cache_ttl_seconds")] @@ -60,11 +60,42 @@ pub struct Hook { } impl Hook { + pub fn merge(mut self, o: Self) -> Self { + if self.cache_ttl_seconds == 0 { + self.cache_ttl_seconds = if o.cache_ttl_seconds == 0 { + DEFAULT_CACHE_TTL_SECONDS + } else { + o.cache_ttl_seconds + }; + } + if self.command.is_empty() { + self.command = o.command; + } + if self.max_output_size == 0 { + self.max_output_size = if o.max_output_size == 0 { + DEFAULT_MAX_OUTPUT_SIZE + } else { + o.max_output_size + }; + } + if self.timeout_ms == 0 { + self.timeout_ms = if o.timeout_ms == 0 { + DEFAULT_TIMEOUT_MS + } else { + o.timeout_ms + }; + } + if self.matcher.is_none() && o.matcher.is_some() { + self.matcher = o.matcher; + } + self + } + fn default_timeout_ms() -> u64 { DEFAULT_TIMEOUT_MS } - fn default_max_output_size() -> usize { + fn default_max_output_size() -> u64 { DEFAULT_MAX_OUTPUT_SIZE } diff --git a/src/agent/mcp_config.rs b/src/agent/mcp_config.rs deleted file mode 100644 index 743e948..0000000 --- a/src/agent/mcp_config.rs +++ /dev/null @@ -1,41 +0,0 @@ -use { - super::custom_tool::CustomToolConfig, - serde::{Deserialize, Serialize}, - std::collections::HashMap, -}; - -#[derive(Clone, Serialize, Deserialize, Debug, Default, Eq, PartialEq)] -#[serde(rename_all = "camelCase", transparent)] -pub struct McpServerConfig { - pub mcp_servers: HashMap, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mcp_server_config_default() { - let config = McpServerConfig::default(); - assert!(config.mcp_servers.is_empty()); - } - - #[test] - fn mcp_server_config_serde() { - let mut config = McpServerConfig::default(); - config.mcp_servers.insert("test".into(), CustomToolConfig { - r#type: Default::default(), - url: String::new(), - headers: HashMap::new(), - oauth: None, - command: "cmd".into(), - args: vec![], - env: HashMap::new(), - timeout: 120_000, - disabled: false, - }); - let json = serde_json::to_string(&config).unwrap(); - let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(config, deserialized); - } -} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 62be2ae..72b472f 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,14 +1,12 @@ mod custom_tool; pub mod hook; -mod mcp_config; pub mod tools; -mod wrapper_types; pub const DEFAULT_AGENT_RESOURCES: &[&str] = &["file://README.md", "file://AGENTS.md"]; pub const DEFAULT_APPROVE: [&str; 0] = []; use { super::agent::hook::{Hook, HookTrigger}, - crate::{Result, kdl::KdlAgent}, - color_eyre::eyre::eyre, + crate::{Result, config::KdlAgent}, + miette::IntoDiagnostic, serde::{Deserialize, Serialize}, std::{ collections::{HashMap, HashSet}, @@ -16,10 +14,8 @@ use { }, }; pub use { - custom_tool::{CustomToolConfig, OAuthConfig, TransportType, tool_default_timeout}, - mcp_config::McpServerConfig, + custom_tool::{CustomToolConfig, tool_default_timeout}, tools::*, - wrapper_types::OriginalToolName, }; #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] @@ -39,7 +35,7 @@ pub struct Agent { pub prompt: Option, /// Configuration for Model Context Protocol (MCP) servers #[serde(default)] - pub mcp_servers: McpServerConfig, + pub mcp_servers: HashMap, /// List of tools the agent can see. Use \"@{MCP_SERVER_NAME}/tool_name\" to /// specify tools from mcp servers. To include all tools from a server, /// use \"@{MCP_SERVER_NAME}\" @@ -47,7 +43,7 @@ pub struct Agent { pub tools: HashSet, /// Tool aliases for remapping tool names #[serde(default)] - pub tool_aliases: HashMap, + pub tool_aliases: HashMap, /// List of tools the agent is explicitly allowed to use #[serde(default)] pub allowed_tools: HashSet, @@ -78,15 +74,17 @@ impl Display for Agent { impl Agent { pub fn validate(&self) -> Result<()> { - let schema: serde_json::Value = serde_json::from_str(crate::schema::SCHEMA)?; - let validator = jsonschema::validator_for(&schema)?; - let instance = serde_json::to_value(self)?; + // TODO cache this + let schema: serde_json::Value = + serde_json::from_str(crate::schema::SCHEMA).into_diagnostic()?; + let validator = jsonschema::validator_for(&schema).into_diagnostic()?; + let instance = serde_json::to_value(self).into_diagnostic()?; if let Err(e) = validator.validate(&instance) { - return Err(eyre!( + return Err(crate::format_err!( "Validation error: {}\n{}", e, - serde_json::to_string(&instance)? + serde_json::to_string(&instance).unwrap_or_default() )); } Ok(()) @@ -94,7 +92,7 @@ impl Agent { } impl TryFrom<&KdlAgent> for Agent { - type Error = color_eyre::Report; + type Error = miette::Report; fn try_from(value: &KdlAgent) -> std::result::Result { let native_tools = &value.native_tool; @@ -105,8 +103,12 @@ impl TryFrom<&KdlAgent> for Agent { if tool != AwsTool::default() { tools_settings.insert( tool_name.to_string(), - serde_json::to_value(&tool) - .map_err(|e| eyre!("Failed to serialize {tool_name} tool configuration {e}"))?, + serde_json::to_value(&tool).map_err(|e| { + crate::format_err!( + "Failed to serialize {tool_name} tool + configuration {e}" + ) + })?, ); } let tool: ReadTool = native_tools.into(); @@ -114,8 +116,12 @@ impl TryFrom<&KdlAgent> for Agent { if tool != ReadTool::default() { tools_settings.insert( tool_name.to_string(), - serde_json::to_value(&tool) - .map_err(|e| eyre!("Failed to serialize {tool_name} tool configuration {e}"))?, + serde_json::to_value(&tool).map_err(|e| { + crate::format_err!( + "Failed to serialize {tool_name} tool + configuration {e}" + ) + })?, ); } let tool: WriteTool = native_tools.into(); @@ -123,8 +129,12 @@ impl TryFrom<&KdlAgent> for Agent { if tool != WriteTool::default() { tools_settings.insert( tool_name.to_string(), - serde_json::to_value(&tool) - .map_err(|e| eyre!("Failed to serialize {tool_name} tool configuration {e}"))?, + serde_json::to_value(&tool).map_err(|e| { + crate::format_err!( + "Failed to serialize {tool_name} tool + configuration {e}" + ) + })?, ); } let tool: ExecuteShellTool = native_tools.into(); @@ -132,32 +142,39 @@ impl TryFrom<&KdlAgent> for Agent { if tool != ExecuteShellTool::default() { tools_settings.insert( tool_name.to_string(), - serde_json::to_value(&tool) - .map_err(|e| eyre!("Failed to serialize {tool_name} tool configuration {e}"))?, + serde_json::to_value(&tool).map_err(|e| { + crate::format_err!( + "Failed to serialize {tool_name} tool + configuration {e}" + ) + })?, ); } let default_agent = Self::default(); - let tools = value.tools().clone(); - let allowed_tools = value.allowed_tools().clone(); - let resources: HashSet = value.resources().map(|s| s.to_string()).collect(); + let tools = value.tools.clone(); + let allowed_tools = value.allowed_tools.clone(); + let resources: HashSet = value.resources.clone(); // Extra tool settings override native tools - let extra_tool_settings = value.extra_tool_settings()?; - tools_settings.extend(extra_tool_settings); + // let extra_tool_settings = value.extra_tool_settings()?; + // tools_settings.extend(extra_tool_settings); + let mut hooks: HashMap> = HashMap::new(); + let triggers: Vec = enum_iterator::all::().collect(); + for t in triggers { + hooks.insert(t, value.hook.hooks(&t)); + } Ok(Self { name: value.name.clone(), description: value.description.clone(), prompt: value.prompt.clone(), - mcp_servers: McpServerConfig { - mcp_servers: value.mcp_servers(), - }, + mcp_servers: value.mcp.clone(), tools: if tools.is_empty() { default_agent.tools } else { tools }, - tool_aliases: value.tool_aliases(), + tool_aliases: value.alias.clone(), allowed_tools: if allowed_tools.is_empty() { default_agent.allowed_tools } else { @@ -168,10 +185,10 @@ impl TryFrom<&KdlAgent> for Agent { } else { resources }, - hooks: value.hooks(), + hooks, tools_settings, model: value.model.clone(), - include_mcp_json: value.include_mcp_json(), + include_mcp_json: value.include_mcp_json.is_some_and(|f| f), }) } } @@ -208,3 +225,23 @@ impl Default for Agent { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_agent() -> crate::Result<()> { + let agent = Agent { + name: "test".to_string(), + ..Default::default() + }; + assert_eq!("test", format!("{agent}")); + + let kg_agent = KdlAgent::default(); + let agent = Agent::try_from(&kg_agent)?; + assert_eq!(agent.tools, Agent::default().tools); + + Ok(()) + } +} diff --git a/src/agent/wrapper_types.rs b/src/agent/wrapper_types.rs deleted file mode 100644 index 7b6d7e1..0000000 --- a/src/agent/wrapper_types.rs +++ /dev/null @@ -1,49 +0,0 @@ -use { - serde::{Deserialize, Serialize}, - std::{borrow::Borrow, hash::Hash, ops::Deref}, -}; - -/// Subject of the tool name change. For tools in mcp servers, you would need to -/// prefix them with their server names -#[derive(Debug, Clone, Serialize, Deserialize, Eq, Hash, PartialEq)] -pub struct OriginalToolName(pub String); - -impl Deref for OriginalToolName { - type Target = String; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Borrow for OriginalToolName { - fn borrow(&self) -> &str { - self.0.as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn original_tool_name_deref() { - let name = OriginalToolName("test".into()); - assert_eq!(&*name, "test"); - } - - #[test] - fn original_tool_name_borrow() { - let name = OriginalToolName("test".into()); - let borrowed: &str = name.borrow(); - assert_eq!(borrowed, "test"); - } - - #[test] - fn original_tool_name_serde() { - let name = OriginalToolName("test".into()); - let json = serde_json::to_string(&name).unwrap(); - let deserialized: OriginalToolName = serde_json::from_str(&json).unwrap(); - assert_eq!(name, deserialized); - } -} diff --git a/src/commands.rs b/src/commands.rs index 0788c12..06f9e1b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -5,7 +5,6 @@ use { Subcommand, builder::{Styles, styling::AnsiColor}, }, - color_eyre::eyre::eyre, std::{io::IsTerminal, path::PathBuf}, }; @@ -121,7 +120,7 @@ impl Cli { /// Return home dir and ~/.kiro/generators/kg.kdl pub fn config(&self) -> crate::Result<(PathBuf, PathBuf)> { - let home_dir = dirs::home_dir().ok_or(eyre!("cannot locate home directory"))?; + let home_dir = dirs::home_dir().ok_or(crate::format_err!("unable to find HOME dir"))?; let cfg = home_dir.join(".kiro").join("generators"); Ok((home_dir, cfg)) } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..36d777c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,388 @@ +mod agent; +mod agent_file; +mod hook; +mod mcp; +mod merge; +mod native; + +pub use agent::{KdlAgent, KdlAgentDoc}; +use { + crate::Fs, + facet::Facet, + facet_kdl as kdl, + miette::IntoDiagnostic, + std::{ + collections::{HashMap, HashSet}, + fmt::Debug, + path::Path, + }, +}; + +pub(crate) type ConfigResult = miette::Result; + +#[derive(Facet, Debug, Default, PartialEq, Clone, Eq, Hash)] +#[facet(default)] +pub(super) struct GenericItem { + #[facet(kdl::argument)] + pub item: String, +} + +#[derive(Facet, Debug, Default, PartialEq, Clone, Eq)] +#[facet(default)] +pub(super) struct GenericSet { + #[facet(kdl::arguments)] + pub item: HashSet, +} + +#[derive(Facet, Debug, Default, PartialEq, Clone, Eq)] +#[facet(default)] +pub(super) struct GenericVec { + #[facet(kdl::arguments)] + pub item: Vec, +} + +impl From for HashMap { + fn from(list: GenericVec) -> HashMap { + list.item + .chunks_exact(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect() + } +} + +#[cfg(test)] +impl GenericVec { + fn len(&self) -> usize { + self.item.len() + } +} + +#[derive(Facet, Copy, Default, Clone, Debug, PartialEq, Eq)] +#[facet(default)] +pub(super) struct IntDoc { + #[facet(kdl::argument)] + pub value: u64, +} + +#[cfg(test)] +fn print_error(e: &facet_kdl::KdlDeserializeError) { + // let d = e.into_diagnostics(); + eprintln!("\n=== Miette render ==="); + let mut output = String::new(); + let handler = miette::GraphicalReportHandler::new_themed(miette::GraphicalTheme::unicode()); + handler.render_report(&mut output, e).unwrap(); + eprintln!("{}", output); +} + +pub(super) fn split_newline(list: Vec) -> HashSet { + list.iter() + .flat_map(|f| f.item.split('\n')) + .map(str::trim) + .filter(|s| !s.is_empty() && s.is_ascii()) + .map(String::from) + .collect() +} + +pub fn kdl_parse_path(fs: &Fs, path: impl AsRef) -> Option> +where + T: for<'a> facet::Facet<'a>, +{ + if fs.exists(&path) { + match fs.read_to_string_sync(&path).into_diagnostic() { + Err(e) => Some(Err(e)), + Ok(content) => match kdl::from_str::(&content) { + Err(e) => { + let kdl_err = + &crate::Error::DeserializeError(path.as_ref().display().to_string(), e); + crate::output::print_error(kdl_err); + Some(Err(crate::format_err!("{kdl_err}"))) + } + Ok(r) => Some(Ok(r)), + }, + } + } else { + None + } +} + +#[cfg(test)] +pub(crate) fn kdl_parse(content: &str) -> ConfigResult +where + T: for<'a> facet::Facet<'a>, +{ + match kdl::from_str::(content) { + Err(e) => { + print_error(&e); + Err(crate::format_err!("{e}")) + } + Ok(r) => Ok(r), + } +} + +#[derive(facet::Facet, Default)] +pub struct GeneratorConfigDoc { + #[facet(facet_kdl::children, default)] + pub agents: Vec, +} + +#[derive(Default)] +pub struct GeneratorConfig { + pub agents: HashMap, +} + +impl From for GeneratorConfig { + fn from(value: GeneratorConfigDoc) -> Self { + let mut agent: HashMap = HashMap::with_capacity(value.agents.len()); + for a in value.agents { + agent.insert(a.name.clone(), a.into()); + } + Self { agents: agent } + } +} + +impl Debug for GeneratorConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "agents={}", self.agents.len()) + } +} + +impl GeneratorConfig { + pub fn get(&self, name: impl AsRef) -> Option<&KdlAgent> { + self.agents.get(name.as_ref()) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::config::agent_file::KdlAgentFileDoc}; + + #[test_log::test] + fn test_agent_decoding() -> ConfigResult<()> { + let kdl_agents = indoc::indoc! {r#" + agent "test" include-mcp-json=#true { + inherits "parent" + description "This is a test agent" + prompt "Generate a test prompt" + resource "file://resource.md" + resource "file://README.md" + tools "*" + allowed-tools "@awsdocs" + hook { + agent-spawn "spawn" { + command "echo i have spawned" + timeout-ms 1000 + max-output-size 9000 + cache-ttl-seconds 2 + } + user-prompt-submit "submit" { + command "echo user submitted" + } + pre-tool-use "pre" { + command "echo before tool" + matcher "git.*" + } + post-tool-use "post" { + command "echo after tool" + } + stop "stop" { + command "echo stopped" + } + } + + mcp "awsdocs" { + command "aws-docs" + args """ + --verbose + --config=/path + """ + env "RUST_LOG" "debug" + env "PATH" "/usr/bin" + header "Authorization" "Bearer token" + timeout 5000 + } + + alias "execute_bash" "shell" + + native-tool { + write { + allow "./src/*" + allow "./scripts/**" + deny "Cargo.lock" + override "/tmp" + override "/var/log" + } + shell deny-by-default=#true { + allow "git status .*" + deny "git push .*" + override "git pull .*" + } + } + } + "#}; + + let config: GeneratorConfigDoc = kdl_parse(kdl_agents)?; + assert_eq!(config.agents.len(), 1); + let config = GeneratorConfig::from(config); + let agent = config.agents.get("test"); + assert!(agent.is_some()); + let agent = agent.unwrap().clone(); + assert_eq!(agent.name, "test"); + assert!(agent.model.is_none()); + assert!(!agent.is_template()); + let inherits = &agent.inherits; + assert_eq!(inherits.len(), 1); + assert_eq!(inherits.iter().next().unwrap(), "parent"); + assert!(agent.description.is_some()); + assert!(agent.prompt.is_some()); + assert!(agent.include_mcp_json.unwrap_or_default()); + let tools = &agent.tools; + assert_eq!(tools.len(), 1); + assert_eq!(tools.iter().next().unwrap(), "*"); + let resources = &agent.resources; + assert_eq!(resources.len(), 2); + assert!(resources.contains(&"file://resource.md".to_string())); + assert!(resources.contains(&"file://README.md".to_string())); + + let hooks = &agent.hook; + let hook = &hooks.agent_spawn.get("spawn"); + assert!(hook.is_some()); + let hook = hook.unwrap(); + assert_eq!(hook.command, "echo i have spawned"); + + // assert!(hooks.contains_key(&HookTrigger::PreToolUse)); + // assert!(hooks.contains_key(&HookTrigger::PostToolUse)); + // assert!(hooks.contains_key(&HookTrigger::Stop)); + // assert!(hooks.contains_key(&HookTrigger::UserPromptSubmit)); + + let allowed = &agent.allowed_tools; + assert_eq!(allowed.len(), 1); + assert_eq!(allowed.iter().next().unwrap(), "@awsdocs"); + + let mcp = &agent.mcp; + assert_eq!(mcp.len(), 1); + assert!(mcp.contains_key("awsdocs")); + let aws_docs = mcp.get("awsdocs").unwrap(); + assert_eq!(aws_docs.command, "aws-docs"); + assert_eq!(aws_docs.args, vec!["--verbose\n--config=/path"]); + assert!(!aws_docs.disabled); + assert_eq!(aws_docs.headers.len(), 1); + assert_eq!(aws_docs.env.len(), 2); + assert_eq!(aws_docs.timeout, 5000); + assert_eq!(agent.alias.len(), 1); + + Ok(()) + } + + #[test_log::test] + fn test_agent_empty() -> ConfigResult<()> { + let kdl_agents = r#" + agent "test" template=#true { + } + "#; + + let config: GeneratorConfigDoc = kdl_parse(kdl_agents)?; + let config = GeneratorConfig::from(config); + assert!(!format!("{config:?}").is_empty()); + assert_eq!(config.agents.len(), 1); + let agent = config.agents.get("test").unwrap(); + assert_eq!(agent.name, "test"); + assert!(agent.model.is_none()); + assert!(agent.is_template()); + + Ok(()) + } + + #[test_log::test] + fn test_agent_file_source() -> ConfigResult<()> { + let kdl_agent_file_source = r#" + description "agent from file" + prompt "Generate a test prompt" + resource "file://resource.md" + resource "file://README.md" + include-mcp-json #true + tools "*" + + allowed-tools "@awsdocs" + hook { + agent-spawn "spawn" { + command "echo i have spawned" + timeout-ms 1000 + max-output-size 9000 + cache-ttl-seconds 2 + } + user-prompt-submit "submit" { + command "echo user submitted" + } + pre-tool-use "pre" { + command "echo before tool" + matcher "git.*" + } + post-tool-use "post" { + command "echo after tool" + } + stop "stop" { + command "echo stopped" + } + } + + mcp "awsdocs" { + command "aws-docs" + args """ + --verbose + --config=/path + """ + env "RUST_LOG" "debug" + env "PATH" "/usr/bin" + header "Authorization" "Bearer token" + timeout 5000 + } + + alias "execute_bash" "shell" + + native-tool { + write { + allow "./src/*" + allow "./scripts/**" + deny "Cargo.lock" + override "/tmp" + override "/var/log" + } + shell deny-by-default=#true { + allow "git status .*" + deny "git push .*" + override "git pull .*" + } + } + "#; + + let agent: KdlAgentFileDoc = kdl_parse(kdl_agent_file_source)?; + assert_eq!( + agent.description.unwrap_or_default().to_string(), + "agent from file" + ); + + Ok(()) + } + + #[test_log::test] + fn test_tool_setting_invalid_json() -> ConfigResult<()> { + let _kdl = r#" + agent "test" { + tool-setting "bad" { + json "{ invalid json }" + } + } + "#; + // TODO + // let config: GeneratorConfig = kdl_parse(kdl)?; + // let result = config.agents[0].extra_tool_settings(); + // assert!(result.is_err()); + // assert!( + // result + // .unwrap_err() + // .to_string() + // .contains("Failed to parse JSON") + // ); + Ok(()) + } +} diff --git a/src/config/agent.rs b/src/config/agent.rs new file mode 100644 index 0000000..fa3fc05 --- /dev/null +++ b/src/config/agent.rs @@ -0,0 +1,246 @@ +use { + super::{ + GenericItem, + hook::{HookDoc, HookPart}, + mcp::CustomToolConfigDoc, + native::{AwsTool, ExecuteShellTool, NativeTools, NativeToolsDoc, ReadTool, WriteTool}, + }, + crate::{ + agent::CustomToolConfig, + config::{GenericSet, GenericVec, split_newline}, + }, + facet::Facet, + facet_kdl as kdl, + std::{ + collections::{HashMap, HashSet}, + fmt::{Debug, Display}, + hash::Hash, + }, +}; + +#[derive(Facet, Clone, Default, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct ToolAliasKdl { + #[facet(default)] + #[facet(kdl::argument)] + from: String, + #[facet(kdl::argument)] + to: String, +} + +#[derive(Facet, Clone, Debug)] +pub struct ToolSetting { + #[facet(kdl::argument)] + name: String, + #[facet(kdl::child)] + json: Json, +} + +#[derive(Facet, Clone, Debug)] +struct Json { + #[facet(kdl::argument)] + value: String, +} + +impl ToolSetting { + #[allow(dead_code)] + fn to_value(&self) -> crate::Result<(String, serde_json::Value)> { + todo!() + // let v: serde_json::Value = serde_json::from_str(&self.json.value) + // .wrap_err_with(|| format!("Failed to parse JSON for tool-setting + // '{}'", self.name))?; + // + // if !v.is_object() { + // return Err(crate::format_err!( + // "tool-setting '{}' must be a JSON object, got: {}", + // self.name, + // v + // )); + // } + // + // Ok((self.name.clone(), v)) + } +} + +#[derive(Clone, Default)] +pub struct KdlAgent { + pub name: String, + pub template: Option, + pub description: Option, + pub inherits: HashSet, + pub prompt: Option, + pub resources: HashSet, + pub include_mcp_json: Option, + pub tools: HashSet, + pub allowed_tools: HashSet, + pub model: Option, + pub hook: HookPart, + pub mcp: HashMap, + pub alias: HashMap, + pub native_tool: NativeTools, + pub tool_setting: Vec, +} + +#[derive(Facet, Clone, Default)] +#[facet(rename_all = "kebab-case", default)] +pub struct KdlAgentDoc { + #[facet(kdl::argument)] + pub name: String, + + #[facet(kdl::property, default)] + pub template: Option, + + #[facet(kdl::child, default)] + pub(super) description: Option, + + #[facet(kdl::child, default)] + pub(super) inherits: GenericSet, + + #[facet(kdl::child, default)] + pub(super) prompt: Option, + + #[facet(kdl::children, default)] + pub(super) resources: Vec, + + #[facet(kdl::property, default)] + pub include_mcp_json: Option, + + #[facet(kdl::child, rename = "tools", default)] + pub(super) tools: GenericSet, + + #[facet(kdl::child, default)] + pub(super) allowed_tools: GenericSet, + + #[facet(kdl::child, default)] + pub(super) model: Option, + + #[facet(kdl::child, default)] + pub(super) hook: Option, + + #[facet(kdl::children, default)] + pub(super) mcp: Vec, + + #[facet(kdl::children, default)] + pub(super) alias: Vec, + + #[facet(kdl::child, default)] + pub native_tool: NativeToolsDoc, + + #[facet(kdl::children, default)] + pub(super) tool_setting: Vec, +} + +impl Debug for KdlAgent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Display for KdlAgent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl From for KdlAgent { + fn from(value: KdlAgentDoc) -> Self { + Self { + name: value.name.clone(), + description: value.description.clone(), + prompt: value.prompt.clone(), + alias: value.tool_aliases(), + allowed_tools: value.allowed_tools(), + inherits: value.inherits(), + template: value.template, + include_mcp_json: value.include_mcp_json, + hook: value.hooks(), + resources: value.resources(), + model: value.model.clone(), + mcp: value.mcp_servers(), + tools: value.tools(), + tool_setting: Default::default(), // TODO use facet::Value + native_tool: value.native_tool.into(), + } + } +} + +impl KdlAgent { + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + pub fn is_template(&self) -> bool { + self.template.is_some_and(|f| f) + } + + pub fn get_tool_aws(&self) -> &AwsTool { + &self.native_tool.aws + } + + pub fn get_tool_read(&self) -> &ReadTool { + &self.native_tool.read + } + + pub fn get_tool_write(&self) -> &WriteTool { + &self.native_tool.write + } + + pub fn get_tool_shell(&self) -> &ExecuteShellTool { + &self.native_tool.shell + } +} + +impl KdlAgentDoc { + pub fn tool_aliases(&self) -> HashMap { + let mut map: HashMap = HashMap::new(); + for a in &self.alias { + let m = HashMap::from(a.clone()); + map.extend(m); + } + map + } + + pub fn hooks(&self) -> HookPart { + HookPart::from(self.hook.clone().unwrap_or_default()) + } + + pub fn allowed_tools(&self) -> HashSet { + self.allowed_tools.item.clone() + } + + pub fn tools(&self) -> HashSet { + self.tools.item.clone() + } + + pub fn inherits(&self) -> HashSet { + self.inherits.item.clone() + } + + pub fn resources(&self) -> HashSet { + split_newline(self.resources.clone()) + } + + pub fn mcp_servers(&self) -> HashMap { + self.mcp + .iter() + .map(|m| (m.name.clone(), m.into())) + .collect() + } + + pub fn extra_tool_settings(&self) -> crate::Result> { + Ok(HashMap::new()) + // for setting in &self.tool_setting { + // let (name, value) = setting.to_value()?; + // if result.contains_key(&name) { + // return Err(color_eyre::eyre::eyre!( + // "[{self}] - Duplicate tool-setting '{}' found. Each + // tool-setting name must be \ unique.", + // name + // )); + // } + // result.insert(name, value); + // } + } +} diff --git a/src/config/agent_file.rs b/src/config/agent_file.rs new file mode 100644 index 0000000..b8deb5e --- /dev/null +++ b/src/config/agent_file.rs @@ -0,0 +1,102 @@ +use { + super::{ + GenericItem, + GenericSet, + agent::*, + hook::HookDoc, + mcp::CustomToolConfigDoc, + native::NativeToolsDoc, + }, + crate::{ + Fs, + config::{ConfigResult, GenericVec}, + }, + facet::Facet, + facet_kdl as kdl, + std::path::Path, +}; + +#[derive(Facet, Copy, Default, Clone, Debug, PartialEq, Eq)] +#[facet(default)] +pub(super) struct BoolDoc { + #[facet(kdl::argument)] + pub value: bool, +} +#[derive(Facet, Clone, Default)] +#[facet(deny_unknown_fields, rename_all = "kebab-case", default)] +pub struct KdlAgentFileDoc { + #[facet(kdl::child, default)] + pub(super) description: Option, + + #[facet(kdl::child, default)] + pub(super) inherits: GenericSet, + + #[facet(kdl::child, default)] + pub(super) prompt: Option, + + #[facet(kdl::children, default)] + pub(super) resources: Vec, + + #[facet(kdl::child, default)] + pub include_mcp_json: Option, + + #[facet(kdl::child, rename = "tools", default)] + pub(super) tools: GenericSet, + + #[facet(kdl::child, default)] + pub(super) allowed_tools: GenericSet, + + #[facet(kdl::child, default)] + pub(super) model: Option, + + #[facet(kdl::child, default)] + pub(super) hook: Option, + + #[facet(kdl::children, default)] + pub(super) mcp: Vec, + + #[facet(kdl::children, default)] + pub(super) alias: Vec, + + #[facet(kdl::child, default)] + pub native_tool: NativeToolsDoc, + + #[facet(kdl::children, default)] + pub(super) tool_setting: Vec, +} + +impl KdlAgentDoc { + pub fn from_path( + fs: &Fs, + name: impl AsRef, + path: impl AsRef, + ) -> Option> { + if let Some(result) = super::kdl_parse_path::(fs, path) { + match result { + Err(e) => return Some(Err(e)), + Ok(file_source) => return Some(Ok(Self::from_file_source(name, file_source))), + } + }; + None + } + + pub fn from_file_source(name: impl AsRef, file_source: KdlAgentFileDoc) -> Self { + Self { + name: name.as_ref().to_string(), + description: file_source.description, + template: None, + inherits: file_source.inherits, + prompt: file_source.prompt, + resources: file_source.resources, + include_mcp_json: file_source.include_mcp_json, + tools: file_source.tools, + allowed_tools: file_source.allowed_tools, + model: file_source.model, + hook: file_source.hook, + mcp: file_source.mcp, + alias: file_source.alias, + native_tool: file_source.native_tool, + tool_setting: file_source.tool_setting, + } + } +} diff --git a/src/config/hook.rs b/src/config/hook.rs new file mode 100644 index 0000000..5b2dfa0 --- /dev/null +++ b/src/config/hook.rs @@ -0,0 +1,286 @@ +use { + crate::agent::hook::{Hook, HookTrigger}, + facet::Facet, + facet_kdl as kdl, + std::collections::HashMap, +}; + +macro_rules! define_hook_doc { + ($name:ident) => { + #[derive(Facet, Default, Clone, Debug, PartialEq, Eq)] + #[facet(default, rename_all = "kebab-case")] + pub struct $name { + #[facet(kdl::argument)] + pub name: String, + #[facet(kdl::child, default)] + command: String, + #[facet(kdl::child, default, rename = "timeout-ms")] + timeout_ms: u64, + #[facet(kdl::child, default, rename = "max-output-size")] + max_output_size: u64, + #[facet(kdl::child, default, rename = "cache-ttl")] + cache_ttl_seconds: u64, + #[facet(kdl::child, default)] + matcher: Option, + } + impl From<$name> for Hook { + fn from(value: $name) -> Hook { + Hook { + command: value.command, + timeout_ms: value.timeout_ms, + max_output_size: value.max_output_size, + cache_ttl_seconds: value.cache_ttl_seconds, + matcher: value.matcher, + } + } + } + }; +} + +#[derive(Facet, Clone, Default, Debug, PartialEq, Eq)] +#[facet(default)] +struct GenericValue { + #[facet(kdl::argument)] + value: String, +} + +define_hook_doc!(HookAgentSpawnDoc); +define_hook_doc!(HookUserPromptSubmitDoc); +define_hook_doc!(HookPreToolUseDoc); +define_hook_doc!(HookPostToolUseDoc); +define_hook_doc!(HookStopDoc); + +#[derive(Facet, Clone, Default, Debug, PartialEq, Eq)] +pub struct HookDoc { + #[facet(kdl::children, default, rename = "agent-spawn")] + pub agent_spawn: Vec, + #[facet(kdl::children, default, rename = "user-prompt-submit")] + pub user_prompt_submit: Vec, + #[facet(kdl::children, default, rename = "pre-tool-use")] + pub pre_tool_use: Vec, + #[facet(kdl::children, default, rename = "post-tool-use")] + pub post_tool_use: Vec, + #[facet(kdl::children, default)] + pub stop: Vec, +} + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct HookPart { + pub agent_spawn: HashMap, + pub user_prompt_submit: HashMap, + pub pre_tool_use: HashMap, + pub post_tool_use: HashMap, + pub stop: HashMap, +} + +impl From for HookPart { + fn from(value: HookDoc) -> Self { + Self { + agent_spawn: value + .agent_spawn + .into_iter() + .map(|h| (h.name.clone(), Hook::from(h))) + .collect(), + user_prompt_submit: value + .user_prompt_submit + .into_iter() + .map(|h| (h.name.clone(), Hook::from(h))) + .collect(), + pre_tool_use: value + .pre_tool_use + .into_iter() + .map(|h| (h.name.clone(), Hook::from(h))) + .collect(), + post_tool_use: value + .post_tool_use + .into_iter() + .map(|h| (h.name.clone(), Hook::from(h))) + .collect(), + stop: value + .stop + .into_iter() + .map(|h| (h.name.clone(), Hook::from(h))) + .collect(), + } + } +} + +impl HookPart { + pub fn hooks(&self, trigger: &HookTrigger) -> Vec { + match trigger { + HookTrigger::AgentSpawn => self.agent_spawn.values().cloned().collect(), + HookTrigger::UserPromptSubmit => self.user_prompt_submit.values().cloned().collect(), + HookTrigger::PreToolUse => self.pre_tool_use.values().cloned().collect(), + HookTrigger::PostToolUse => self.post_tool_use.values().cloned().collect(), + HookTrigger::Stop => self.stop.values().cloned().collect(), + } + } + + pub fn merge(mut self, other: Self) -> Self { + self.agent_spawn = merge_hooks(self.agent_spawn, other.agent_spawn); + self.user_prompt_submit = merge_hooks(self.user_prompt_submit, other.user_prompt_submit); + self.pre_tool_use = merge_hooks(self.pre_tool_use, other.pre_tool_use); + self.post_tool_use = merge_hooks(self.post_tool_use, other.post_tool_use); + self.stop = merge_hooks(self.stop, other.stop); + self + } +} + +fn merge_hooks( + mut base: HashMap, + other: HashMap, +) -> HashMap { + if base.is_empty() { + return other; + } + if other.is_empty() { + return base; + } + + for (key, other_hook) in other { + base.entry(key) + .and_modify(|h| *h = h.clone().merge(other_hook.clone())) + .or_insert(other_hook); + } + base +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{Result, config::kdl_parse}, + std::time::Duration, + }; + + fn rando() -> HashMap { + let value = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let name = format!("$name-{value}"); + let mut hooks = HashMap::new(); + hooks.insert(name, Hook { + command: format!("{value}"), + timeout_ms: value, + max_output_size: value, + cache_ttl_seconds: value, + matcher: Some(format!("{value}")), + }); + hooks + } + + impl HookPart { + pub fn randomize() -> Self { + Self { + agent_spawn: rando(), + user_prompt_submit: rando(), + pre_tool_use: rando(), + post_tool_use: rando(), + stop: rando(), + } + } + } + #[test_log::test] + pub fn test_hooks_kdl() -> Result<()> { + let kdl = r#" + agent-spawn "test" { + command "echo" + timeout-ms 1231 + max-output-size 69 + cache-ttl-seconds 666 + matcher "blah" + } + user-prompt-submit "prompt-hook" { + command "validate-prompt" + timeout-ms 500 + } + pre-tool-use "pre-hook" { + command "pre-check" + matcher "git*" + } + post-tool-use "post-hook" { + command "post-check" + } + stop "cleanup" { + command "cleanup-script" + cache-ttl-seconds 0 + } + "#; + let doc: HookDoc = kdl_parse(kdl)?; + let doc = HookPart::from(doc); + + assert_eq!(1, doc.agent_spawn.len()); + let hook = doc.agent_spawn.get("test"); + assert!(hook.is_some()); + let hook = hook.unwrap(); + assert_eq!(hook.command, "echo"); + assert_eq!(hook.timeout_ms, 1231); + assert_eq!(hook.max_output_size, 69); + + assert_eq!(1, doc.user_prompt_submit.len()); + assert!(doc.user_prompt_submit.contains_key("prompt-hook")); + + assert_eq!(1, doc.pre_tool_use.len()); + let pre = doc.pre_tool_use.get("pre-hook").unwrap(); + assert_eq!(pre.matcher, Some("git*".to_string())); + + assert_eq!(1, doc.post_tool_use.len()); + assert!(doc.post_tool_use.contains_key("post-hook")); + + assert_eq!(1, doc.stop.len()); + assert!(doc.stop.contains_key("cleanup")); + + Ok(()) + } + + #[test_log::test] + pub fn test_hooks_empty() -> Result<()> { + let child = HookPart::default(); + let parent = HookPart::default(); + let merged = child.merge(parent); + + assert!(merged.agent_spawn.is_empty()); + assert!(merged.user_prompt_submit.is_empty()); + assert!(merged.pre_tool_use.is_empty()); + assert!(merged.post_tool_use.is_empty()); + assert!(merged.stop.is_empty()); + Ok(()) + } + + #[test_log::test] + pub fn test_hooks_empty_child() -> Result<()> { + let child = HookPart::default(); + let parent = HookPart::randomize(); + let before = parent.clone(); + let merged = child.merge(parent); + + assert_eq!(merged, before); + Ok(()) + } + + #[test_log::test] + pub fn test_hooks_no_merge() -> Result<()> { + let child = HookPart::randomize(); + let parent = HookPart::randomize(); + let before = child.clone(); + let merged = child.merge(parent); + assert_eq!(merged, before); + Ok(()) + } + + #[test_log::test] + pub fn test_hooks_merge_parent() -> Result<()> { + let child = HookPart::randomize(); + std::thread::sleep(Duration::from_millis(1300)); + let parent = HookPart::randomize(); + let merged = child.merge(parent); + assert_eq!(merged.agent_spawn.len(), 2); + assert_eq!(merged.user_prompt_submit.len(), 2); + assert_eq!(merged.pre_tool_use.len(), 2); + assert_eq!(merged.post_tool_use.len(), 2); + assert_eq!(merged.stop.len(), 2); + Ok(()) + } +} diff --git a/src/config/mcp.rs b/src/config/mcp.rs new file mode 100644 index 0000000..7655b3d --- /dev/null +++ b/src/config/mcp.rs @@ -0,0 +1,183 @@ +use { + crate::{agent::CustomToolConfig, config::GenericVec}, + facet::Facet, + facet_kdl as kdl, +}; + +#[derive(Facet, Clone, Debug)] +struct KeyVal { + #[facet(kdl::argument)] + key: String, + #[facet(kdl::argument)] + value: String, +} + +#[derive(Facet, Default, Clone, Debug)] +#[facet(rename_all = "kebab-case", default)] +pub struct CustomToolConfigDoc { + #[facet(kdl::argument)] + pub name: String, + + #[facet(kdl::child, default)] + pub url: String, + + #[facet(kdl::child, default)] + pub command: String, + + #[facet(kdl::child, default)] + args: GenericVec, + + #[facet(kdl::child, default)] + env: GenericVec, + + #[facet(kdl::child, default)] + header: GenericVec, + + #[facet(kdl::child, default)] + pub(super) timeout: u64, + + #[facet(kdl::property, default)] + pub disabled: bool, +} + +impl From for CustomToolConfig { + fn from(value: CustomToolConfigDoc) -> Self { + Self { + url: value.url, + command: value.command, + args: value.args.item.into_iter().collect(), + timeout: if value.timeout == 0 { + crate::agent::tool_default_timeout() + } else { + value.timeout + }, + disabled: value.disabled, + headers: value.header.into(), + env: value.env.into(), + } + } +} + +impl From<&CustomToolConfigDoc> for CustomToolConfig { + fn from(value: &CustomToolConfigDoc) -> Self { + value.clone().into() + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::config::{ConfigResult, kdl_parse}, + indoc::indoc, + std::collections::HashMap, + }; + + #[derive(Facet, Debug)] + struct McpDoc { + #[facet(kdl::child)] + mcp: CustomToolConfigDoc, + } + + #[test] + fn parse_basic_mcp() -> ConfigResult<()> { + let kdl = indoc! { + r#"mcp "rustdocs" { + command "rust-docs-mcp" + timeout 1000 + }"# + }; + + let doc: McpDoc = kdl_parse(kdl)?; + assert_eq!(doc.mcp.name, "rustdocs"); + assert_eq!(doc.mcp.command, "rust-docs-mcp"); + assert_eq!(doc.mcp.timeout, 1000); + Ok(()) + } + + #[test] + fn parse_mcp_with_url() -> ConfigResult<()> { + let kdl = r#"mcp "remote" { + url "http://localhost:8080" + }"#; + + let doc: McpDoc = facet_kdl::from_str(kdl)?; + assert_eq!(doc.mcp.name, "remote"); + assert_eq!(doc.mcp.url, "http://localhost:8080"); + Ok(()) + } + + #[test] + fn parse_mcp_with_env_and_headers() -> ConfigResult<()> { + let kdl = r#"mcp "api" { + command "api-server" + env "API_KEY" "secret123" + env "DEBUG" "true" + header "Authorization" "Bearer token" + header "Content-Type" "application/json" + }"#; + let doc: McpDoc = facet_kdl::from_str(kdl)?; + assert_eq!(doc.mcp.env.len(), 4); + assert_eq!(doc.mcp.header.len(), 4); + + let env: HashMap = doc.mcp.env.into(); + assert_eq!(env.len(), 2); + assert_eq!(env.get("API_KEY"), Some(&"secret123".to_string())); + assert_eq!(env.get("DEBUG"), Some(&"true".to_string())); + + let header: HashMap = doc.mcp.header.into(); + assert_eq!(header.len(), 2); + assert_eq!( + header.get("Authorization"), + Some(&"Bearer token".to_string()) + ); + assert_eq!( + header.get("Content-Type"), + Some(&"application/json".to_string()) + ); + Ok(()) + } + + #[test] + fn parse_mcp_with_args() -> ConfigResult<()> { + let kdl = indoc! { r#"mcp "tool" { + command "my-tool" + args "--verbose" "--output=json" + }"# + }; + + let doc: McpDoc = kdl_parse(kdl)?; + assert_eq!(doc.mcp.args.item, vec!["--verbose", "--output=json"]); + Ok(()) + } + + #[test] + fn convert_to_custom_tool_config() -> ConfigResult<()> { + let kdl = r#"mcp "test" disabled=#true { + command "test-cmd" + timeout 5000 + }"#; + + let doc: McpDoc = facet_kdl::from_str(kdl)?; + let config: CustomToolConfig = doc.mcp.into(); + + assert_eq!(config.command, "test-cmd"); + assert_eq!(config.timeout, 5000); + assert!(config.disabled); + Ok(()) + } + + #[test] + fn default_timeout_when_zero() -> ConfigResult<()> { + let kdl = r#"mcp "test" { + command "test-cmd" + timeout 0 + }"#; + + let doc: McpDoc = facet_kdl::from_str(kdl)?; + let config: CustomToolConfig = doc.mcp.into(); + + assert_eq!(config.timeout, crate::agent::tool_default_timeout()); + Ok(()) + } +} diff --git a/src/config/merge.rs b/src/config/merge.rs new file mode 100644 index 0000000..54bb4f2 --- /dev/null +++ b/src/config/merge.rs @@ -0,0 +1,186 @@ +use super::*; + +impl KdlAgent { + pub fn merge(mut self, other: KdlAgent) -> Self { + // Child wins for explicit values + self.include_mcp_json = self.include_mcp_json.or(other.include_mcp_json); + self.template = self.template.or(other.template); + self.description = self.description.or(other.description); + self.prompt = self.prompt.or(other.prompt); + self.model = self.model.or(other.model); + + // Collections are extended (merged) + self.resources.extend(other.resources); + self.tools.extend(other.tools); + self.allowed_tools.extend(other.allowed_tools); + self.alias.extend(other.alias); + self.mcp.extend(other.mcp); + self.inherits.extend(other.inherits); + self.tool_setting.extend(other.tool_setting); + + self.hook = self.hook.merge(other.hook); + self.native_tool = self.native_tool.merge(other.native_tool); + + self + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{agent::hook::HookTrigger, config}, + }; + + #[test_log::test] + fn test_agent_merge() -> config::ConfigResult<()> { + let kdl_agents = indoc::indoc! {r#" + agent "child" template=#false include-mcp-json=#true { + description "I am a child" + resource "file://child.md" + resource "file://README.md" + inherits "parent" + tools "@awsdocs" "shell" + native-tool { + write { + overrides "Cargo.lock" + } + shell { + overrides "git push .*" + } + } + hook { + agent-spawn "spawn" { + command "echo i have spawned" + max-output-size 9000 + cache-ttl-seconds 2 + } + } + alias "execute_bash" "shell" + } + agent "parent" template=#true { + description "I am parent" + resource "file://parent.md" + resource "file://README.md" + tools "web_search" "shell" + prompt "i tell you what to do" + model "claude" + allowed-tools "write" + alias "execute_bash" "shell" + alias "fs_read" "read" + native-tool { + read { + allows "./src/*" "./scripts/**" + denies "Cargo.lock" + } + write { + allows "./src/*" "./scripts/**" + denies "Cargo.lock" + } + + shell { + allows "git status .*" "git pull .*" + denies "git push .*" + } + } + hook { + agent-spawn "spawn" { + timeout-ms 1111 + } + user-prompt-submit "submit" { + command "echo user submitted" + timeout-ms 1000 + } + pre-tool-use "pre" { + command "echo before tool" + matcher "git.*" + } + post-tool-use "post" { + command "echo after tool" + } + stop "stop" { + command "echo stopped" + } + } + } + "#}; + + let config: GeneratorConfigDoc = config::kdl_parse(kdl_agents)?; + assert_eq!(config.agents.len(), 2); + let config = GeneratorConfig::from(config); + let child = config.agents.get("child"); + let parent = config.agents.get("parent"); + assert!(child.is_some()); + assert!(parent.is_some()); + let child = child.unwrap().clone(); + let parent = parent.unwrap().clone(); + assert_eq!("child", child.name); + assert_eq!("parent", parent.name); + assert!(!child.tools.is_empty()); + assert!(!parent.tools.is_empty()); + assert!(parent.is_template()); + let merged = child.merge(parent); + assert!(merged.description.is_some()); + let d = merged.description.clone().unwrap(); + assert_eq!(d, "I am a child"); + + assert_eq!(merged.resources.len(), 3); + assert!(!merged.is_template()); + assert!(merged.include_mcp_json.unwrap_or_default()); + + assert_eq!(merged.inherits.len(), 1); + assert!(merged.inherits.contains("parent")); + + assert_eq!(merged.prompt, Some("i tell you what to do".to_string())); + let tools = &merged.tools; + assert_eq!(tools.len(), 3); + assert!(tools.contains("@awsdocs")); + assert!(tools.contains("shell")); + assert!(tools.contains("web_search")); + + assert_eq!(merged.model, Some("claude".to_string())); + + let allowed_tools = &merged.allowed_tools; + assert_eq!(allowed_tools.len(), 1); + assert!(allowed_tools.contains("write")); + + let hooks = &merged.hook.hooks(&HookTrigger::AgentSpawn); + assert!(!hooks.is_empty()); + assert_eq!(hooks[0].timeout_ms, 1111); + assert_eq!(hooks[0].command, "echo i have spawned"); + + let hooks = &merged.hook.hooks(&HookTrigger::UserPromptSubmit); + assert!(!hooks.is_empty()); + assert_eq!(hooks[0].command, "echo user submitted"); + assert_eq!(hooks[0].timeout_ms, 1000); + + let alias = &merged.alias; + assert_eq!(alias.len(), 2); + assert!(alias.contains_key("fs_read")); + assert!(alias.contains_key("execute_bash")); + + let tool = merged.get_tool_write(); + assert!(tool.overrides.contains("Cargo.lock")); + assert_eq!(tool.allows.len(), 2); + assert_eq!(tool.overrides.len(), 1); + assert_eq!(tool.denies.len(), 1); + + let tool = merged.get_tool_read(); + assert_eq!(tool.allows.len(), 2); + assert_eq!(tool.overrides.len(), 0); + assert_eq!(tool.denies.len(), 1); + + let tool = merged.get_tool_shell(); + assert_eq!(tool.allows.len(), 2); + assert_eq!(tool.overrides.len(), 1); + assert_eq!(tool.denies.len(), 1); + + let tool = merged.get_tool_aws(); + assert!(tool.allows.is_empty()); + assert!(tool.denies.is_empty()); + + assert_eq!("child", format!("{merged}")); + assert_eq!("child", format!("{merged:?}")); + Ok(()) + } +} diff --git a/src/config/native.rs b/src/config/native.rs new file mode 100644 index 0000000..bde8881 --- /dev/null +++ b/src/config/native.rs @@ -0,0 +1,536 @@ +use { + super::GenericSet, + crate::agent::{ + AwsTool as KiroAwsTool, + ExecuteShellTool as KiroShellTool, + ReadTool as KiroReadTool, + WriteTool as KiroWriteTool, + }, + facet::Facet, + facet_kdl as kdl, + std::collections::HashSet, +}; + +macro_rules! define_tool { + ($name:ident) => { + #[derive(Clone, Debug, Default, PartialEq, Eq)] + pub struct $name { + pub allows: HashSet, + pub denies: HashSet, + pub overrides: HashSet, + pub disable_auto_readonly: Option, + pub deny_by_default: Option, + } + + impl $name { + pub fn merge(mut self, other: Self) -> Self { + if !other.allows.is_empty() { + tracing::trace!( + tool = stringify!($name), + count = other.allows.len(), + "merging allows" + ); + self.allows.extend(other.allows); + } + if !other.denies.is_empty() { + tracing::trace!( + tool = stringify!($name), + count = other.denies.len(), + "merging denies" + ); + self.denies.extend(other.denies); + } + if !other.overrides.is_empty() { + tracing::trace!( + tool = stringify!($name), + count = other.overrides.len(), + "merging overrides" + ); + self.overrides.extend(other.overrides); + } + self.disable_auto_readonly = + self.disable_auto_readonly.or(other.disable_auto_readonly); + self.deny_by_default = self.deny_by_default.or(other.deny_by_default); + self + } + } + }; +} + +macro_rules! define_kdl_doc { + ($name:ident) => { + #[derive(Facet, Clone, Debug, Default, PartialEq, Eq)] + #[facet(default, rename_all = "kebab-case")] + pub struct $name { + #[facet(default, kdl::child)] + pub(super) allows: GenericSet, + #[facet(default, kdl::child)] + pub(super) denies: GenericSet, + #[facet(default, kdl::child)] + pub(super) overrides: GenericSet, + #[facet(default, kdl::property)] + pub deny_by_default: Option, + #[facet(default, kdl::property)] + pub disable_auto_readonly: Option, + } + }; +} + +macro_rules! define_tool_into { + ($name:ident, $to:ident) => { + impl From<$name> for $to { + fn from(value: $name) -> $to { + $to { + allows: value.allows.item, + denies: value.denies.item, + overrides: value.overrides.item, + deny_by_default: value.deny_by_default, + disable_auto_readonly: value.disable_auto_readonly, + } + } + } + }; +} + +define_kdl_doc!(AwsToolDoc); +define_kdl_doc!(ExecuteShellToolDoc); +define_kdl_doc!(WriteToolDoc); +define_kdl_doc!(ReadToolDoc); +define_tool!(ExecuteShellTool); +define_tool!(AwsTool); +define_tool!(WriteTool); +define_tool!(ReadTool); +define_tool_into!(ExecuteShellToolDoc, ExecuteShellTool); +define_tool_into!(AwsToolDoc, AwsTool); +define_tool_into!(WriteToolDoc, WriteTool); +define_tool_into!(ReadToolDoc, ReadTool); + +#[derive(Facet, Default, Clone, Debug, PartialEq, Eq)] +#[facet(default)] +pub struct NativeToolsDoc { + #[facet(default, kdl::child)] + pub shell: ExecuteShellToolDoc, + #[facet(default, kdl::child)] + pub aws: AwsToolDoc, + #[facet(default, kdl::child)] + pub read: ReadToolDoc, + #[facet(default, kdl::child)] + pub write: WriteToolDoc, +} + +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct NativeTools { + pub shell: ExecuteShellTool, + pub aws: AwsTool, + pub read: ReadTool, + pub write: WriteTool, +} + +impl From for NativeTools { + fn from(value: NativeToolsDoc) -> Self { + Self { + shell: value.shell.into(), + aws: value.aws.into(), + read: value.read.into(), + write: value.write.into(), + } + } +} + +impl NativeTools { + pub fn merge(mut self, other: Self) -> Self { + self.shell = self.shell.merge(other.shell); + self.aws = self.aws.merge(other.aws); + self.read = self.read.merge(other.read); + self.write = self.write.merge(other.write); + self + } +} + +impl From<&NativeTools> for KiroAwsTool { + fn from(value: &NativeTools) -> Self { + let aws = &value.aws; + KiroAwsTool { + allowed_services: aws.allows.clone(), + denied_services: aws.denies.clone(), + auto_allow_readonly: !aws.disable_auto_readonly.unwrap_or(false), + } + } +} + +impl From<&NativeTools> for KiroWriteTool { + fn from(value: &NativeTools) -> Self { + let write = &value.write; + let mut allows: HashSet = write.allows.clone(); + let mut denies: HashSet = write.denies.clone(); + if !write.overrides.is_empty() { + tracing::trace!( + "Override/Forcing write: {:?}", + write.overrides.iter().collect::>() + ); + for cmd in write.overrides.iter() { + allows.insert(cmd.clone()); + if denies.remove(cmd) { + tracing::trace!("Removed from denies: {cmd}"); + } + } + } + + Self { + allowed_paths: allows, + denied_paths: denies, + } + } +} + +impl From<&NativeTools> for KiroReadTool { + fn from(value: &NativeTools) -> Self { + let read = &value.read; + let mut allows: HashSet = read.allows.clone(); + let mut denies: HashSet = read.denies.clone(); + if !read.overrides.is_empty() { + tracing::trace!( + "Override/Forcing write: {:?}", + read.overrides.iter().collect::>() + ); + for cmd in read.overrides.iter() { + allows.insert(cmd.clone()); + if denies.remove(cmd) { + tracing::trace!("Removed from denies: {cmd}"); + } + } + } + + Self { + allowed_paths: allows, + denied_paths: denies, + } + } +} + +impl From<&NativeTools> for KiroShellTool { + fn from(value: &NativeTools) -> Self { + let shell = &value.shell; + let mut allows: HashSet = shell.allows.clone(); + let mut denies: HashSet = shell.denies.clone(); + + if !shell.overrides.is_empty() { + tracing::trace!( + "Override/Forcing commands: {:?}", + shell.overrides.iter().collect::>() + ); + for cmd in shell.overrides.iter() { + allows.insert(cmd.clone()); + if denies.remove(cmd) { + tracing::trace!("Removed command from denies: {cmd}"); + } + } + } + Self { + allowed_commands: allows, + denied_commands: denies, + deny_by_default: shell.deny_by_default.unwrap_or(false), + auto_allow_readonly: shell.disable_auto_readonly.unwrap_or(true), + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{ + Result, + config::{ConfigResult, kdl_parse}, + }, + std::fmt::Display, + }; + fn into_set(v: Vec) -> HashSet { + HashSet::from_iter(v.into_iter().map(|t| t.to_string())) + } + #[test_log::test] + fn parse_shell_tool() -> ConfigResult<()> { + let kdl = r#" + shell deny-by-default=#true disable-auto-readonly=#false { + allows "ls .*" "git status" + denies "rm -rf /" + overrides "git push" + } + "#; + + let doc: NativeToolsDoc = kdl_parse(kdl)?; + let doc = NativeTools::from(doc); + let shell = doc.shell; + assert_eq!(shell.allows.len(), 2); + assert_eq!(shell.denies.len(), 1); + assert!(shell.deny_by_default.unwrap_or_default()); + assert!(!shell.disable_auto_readonly.unwrap_or_default()); + assert_eq!(shell.overrides.len(), 1); + Ok(()) + } + + #[test_log::test] + fn parse_aws_tool() -> ConfigResult<()> { + let kdl = r#" + aws disable-auto-readonly=#true { + allows "ec2" "s3" + denies "iam" + } + "#; + + let doc: NativeToolsDoc = kdl_parse(kdl)?; + let aws = NativeTools::from(doc).aws; + assert!(aws.disable_auto_readonly.is_some()); + assert!(aws.disable_auto_readonly.unwrap_or_default()); + assert_eq!(aws.allows.len(), 2); + assert_eq!(aws.denies.len(), 1); + Ok(()) + } + + #[test_log::test] + fn parse_read_write_tools() -> ConfigResult<()> { + let kdl = r#" + read { + allows "*.rs" "*.toml" + denies "/etc/*" + overrides "/etc/hosts" + } + write { + allows "*.txt" + denies "/tmp/*" + overrides "/tmp/allowed" + } + "#; + + let doc: NativeToolsDoc = kdl_parse(kdl)?; + let doc = NativeTools::from(doc); + assert_eq!(doc.read.allows.len(), 2); + assert_eq!(doc.write.allows.len(), 1); + Ok(()) + } + + #[test_log::test] + pub fn test_native_merge_empty() -> Result<()> { + let child = NativeTools::default(); + let parent = NativeTools::default(); + let merged = child.merge(parent); + + assert_eq!(merged, NativeTools::default()); + Ok(()) + } + + #[test_log::test] + pub fn test_native_merge_empty_child() -> Result<()> { + let child = NativeTools::default(); + let parent = NativeTools { + aws: AwsTool { + disable_auto_readonly: None, + deny_by_default: None, + overrides: Default::default(), + allows: into_set(vec!["ec2"]), + denies: into_set(vec!["iam"]), + }, + shell: ExecuteShellTool { + allows: into_set(vec!["ls .*"]), + denies: into_set(vec!["git push"]), + overrides: into_set(vec!["rm -rf /"]), + deny_by_default: Some(true), + disable_auto_readonly: Some(false), + }, + read: ReadTool { + allows: into_set(vec!["ls .*"]), + denies: into_set(vec!["git push"]), + overrides: into_set(vec!["rm -rf /"]), + ..Default::default() + }, + write: WriteTool { + allows: into_set(vec!["ls .*"]), + denies: into_set(vec!["git push"]), + overrides: into_set(vec!["rm -rf /"]), + ..Default::default() + }, + }; + + let merged = child.merge(parent.clone()); + assert_eq!(merged.aws, parent.aws); + assert_eq!(merged.shell, parent.shell); + assert_eq!(merged.read, parent.read); + assert_eq!(merged.write, parent.write); + Ok(()) + } + + #[test_log::test] + pub fn test_native_merge_child_parent() -> Result<()> { + let child = NativeTools { + aws: AwsTool { + disable_auto_readonly: Some(true), + allows: into_set(vec!["ec2"]), + ..Default::default() + }, + ..Default::default() + }; + + let parent = NativeTools { + aws: AwsTool { + allows: into_set(vec!["ec2"]), + denies: into_set(vec!["iam"]), + ..Default::default() + }, + ..Default::default() + }; + + let merged = child.merge(parent); + let aws = merged.aws; + assert!(aws.disable_auto_readonly.unwrap_or_default()); + // Should have deduplicated ec2 + assert_eq!(aws.allows.len(), 1); + assert_eq!(aws.denies, into_set(vec!["iam".to_string()])); + Ok(()) + } + + #[test_log::test] + pub fn test_native_merge_shell() -> Result<()> { + let child = ExecuteShellTool::default(); + let parent = ExecuteShellTool { + deny_by_default: Some(false), + disable_auto_readonly: Some(false), + ..Default::default() + }; + + let merged = child.clone().merge(parent); + assert!(!merged.deny_by_default.unwrap_or_default()); + assert!(!merged.disable_auto_readonly.unwrap_or_default()); + + let parent = ExecuteShellTool { + deny_by_default: Some(true), + disable_auto_readonly: Some(true), + ..Default::default() + }; + let merged = child.clone().merge(parent); + assert!(merged.deny_by_default.unwrap_or_default()); + assert!(merged.disable_auto_readonly.unwrap_or_default()); + + let child = ExecuteShellTool { + deny_by_default: Some(false), + disable_auto_readonly: Some(false), + ..Default::default() + }; + let parent = ExecuteShellTool { + deny_by_default: Some(true), + disable_auto_readonly: Some(true), + ..Default::default() + }; + let merged = child.merge(parent); + assert!(!merged.deny_by_default.unwrap_or_default()); + assert!(!merged.disable_auto_readonly.unwrap_or_default()); + Ok(()) + } + + #[test_log::test] + pub fn test_native_aws_kiro() -> Result<()> { + let a = NativeTools::default(); + let kiro = KiroAwsTool::from(&a); + assert!(kiro.auto_allow_readonly); + assert!(kiro.allowed_services.is_empty()); + assert!(kiro.denied_services.is_empty()); + + let a = NativeTools { + aws: AwsTool { + disable_auto_readonly: Some(true), + allows: into_set(vec!["blah"]), + denies: into_set(vec!["blahblah"]), + ..Default::default() + }, + ..Default::default() + }; + + let kiro = KiroAwsTool::from(&a); + assert!(!kiro.auto_allow_readonly); + assert!(kiro.allowed_services.contains("blah")); + assert!(kiro.denied_services.contains("blahblah")); + assert_eq!(kiro.allowed_services.len() + kiro.denied_services.len(), 2); + Ok(()) + } + + #[test_log::test] + pub fn test_native_shell_kiro() -> Result<()> { + let a = NativeTools::default(); + let kiro = KiroShellTool::from(&a); + assert!(kiro.auto_allow_readonly); + assert!(kiro.allowed_commands.is_empty()); + assert!(kiro.denied_commands.is_empty()); + + let a = NativeTools { + shell: ExecuteShellTool { + allows: into_set(vec!["ls"]), + denies: into_set(vec!["rm"]), + deny_by_default: None, + disable_auto_readonly: None, + overrides: into_set(vec!["rm"]), + }, + ..Default::default() + }; + let kiro = KiroShellTool::from(&a); + assert!(kiro.auto_allow_readonly); + assert_eq!(kiro.allowed_commands.len(), 2); + assert_eq!( + kiro.allowed_commands, + into_set(vec!["ls".to_string(), "rm".to_string()]) + ); + assert!(kiro.denied_commands.is_empty()); + Ok(()) + } + + #[test_log::test] + pub fn test_native_read_kiro() -> Result<()> { + let a = NativeTools::default(); + let kiro = KiroReadTool::from(&a); + assert!(kiro.allowed_paths.is_empty()); + assert!(kiro.denied_paths.is_empty()); + + let a = NativeTools { + read: ReadTool { + allows: into_set(vec!["ls"]), + denies: into_set(vec!["rm"]), + overrides: into_set(vec!["rm"]), + ..Default::default() + }, + ..Default::default() + }; + let kiro = KiroReadTool::from(&a); + assert_eq!(kiro.allowed_paths.len(), 2); + assert_eq!( + kiro.allowed_paths, + into_set(vec!["ls".to_string(), "rm".to_string()]) + ); + assert!(kiro.denied_paths.is_empty()); + Ok(()) + } + + #[test_log::test] + pub fn test_native_write_kiro() -> Result<()> { + let a = NativeTools::default(); + let kiro = KiroWriteTool::from(&a); + assert!(kiro.allowed_paths.is_empty()); + assert!(kiro.denied_paths.is_empty()); + let write = WriteTool { + allows: into_set(vec!["ls"]), + denies: into_set(vec!["rm"]), + overrides: into_set(vec!["rm"]), + ..Default::default() + }; + let a = NativeTools { + write, + ..Default::default() + }; + + let kiro = KiroWriteTool::from(&a); + assert_eq!(kiro.allowed_paths.len(), 2); + assert_eq!( + kiro.allowed_paths, + into_set(vec!["ls".to_string(), "rm".to_string()]) + ); + assert!(kiro.denied_paths.is_empty()); + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1e7a86b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,5 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("syntax error in file {0}")] + DeserializeError(String, facet_kdl::KdlDeserializeError), +} diff --git a/src/generator/config_location.rs b/src/generator/config_location.rs index 273a3dd..58e06bb 100644 --- a/src/generator/config_location.rs +++ b/src/generator/config_location.rs @@ -49,7 +49,7 @@ impl ConfigLocation { let local_exists = fs.exists(self.local_kg()); if !global_exists && !local_exists { - return Err(eyre!( + return Err(crate::format_err!( "no kg.kdl found at global ({}) or local ({})", self.global_kg().display(), self.local_kg().display() diff --git a/src/generator/discover.rs b/src/generator/discover.rs index 9d2846f..aa467e4 100644 --- a/src/generator/discover.rs +++ b/src/generator/discover.rs @@ -1,25 +1,17 @@ use { super::*, - crate::kdl::{GeneratorConfig, KdlAgent}, - knuffel::parse, + crate::config::{GeneratorConfig, GeneratorConfigDoc, KdlAgent, KdlAgentDoc}, std::{fmt::Display, ops::Deref, path::Path}, }; pub fn load_inline(fs: &Fs, path: impl AsRef) -> Result { - if fs.exists(&path) { - let content = fs - .read_to_string_sync(&path) - .wrap_err_with(|| format!("failed to read path '{}'", path.as_ref().display()))?; - match parse(&format!("{}", path.as_ref().display()), &content) { - Ok(c) => Ok(c), - Err(e) => { - let err_msg = e.to_string(); - eprintln!("{:?}", miette::Report::new(e)); - Err(eyre!("failed to parse: {err_msg}")) - } + let doc: Option> = crate::config::kdl_parse_path(fs, path); + match doc { + None => Ok(GeneratorConfig::default()), + Some(d) => { + let d = d?; + Ok(d.into()) } - } else { - Ok(GeneratorConfig::default()) } } @@ -31,16 +23,17 @@ fn process_local( sources: &mut Vec, ) -> Result { let local_agent_path = location.local(&name); - let result = KdlAgent::from_path(fs, &name, &local_agent_path)?; + let result = KdlAgentDoc::from_path(fs, &name, &local_agent_path); match result { - None => Ok(KdlAgent::new(name)), + None => Ok(KdlAgent::new(name.as_ref().to_string())), Some(a) => { + let agent = KdlAgent::from(a?.clone()); sources.push(KdlAgentSource::LocalFile(local_agent_path)); if let Some(i) = inline { sources.push(KdlAgentSource::LocalInline); - Ok(a.merge(i.clone())) + Ok(agent.merge(i.clone())) } else { - Ok(a) + Ok(agent) } } } @@ -98,8 +91,10 @@ pub fn discover( let local_agents: GeneratorConfig = load_inline(fs, local_path)?; tracing::debug!("found {} local agents", local_agents.agents.len()); - let local_names = local_agents.names(); - let global_names = global_agents.names(); + let local_names: HashSet = + HashSet::from_iter(local_agents.agents.keys().map(|k| k.to_string())); + let global_names: HashSet = + HashSet::from_iter(global_agents.agents.keys().map(|k| k.to_string())); let mut all_agents_names: HashSet = HashSet::with_capacity(global_names.len() + local_names.len()); all_agents_names.extend(local_names.clone()); @@ -128,19 +123,20 @@ pub fn discover( agent_sources.push(KdlAgentSource::GlobalInline); result = result.merge(a.clone()); } - let maybe_global_file = KdlAgent::from_path(fs, name, location.global(name))?; + let maybe_global_file = KdlAgentDoc::from_path(fs, name, location.global(name)); if let Some(global) = maybe_global_file { agent_sources.push(KdlAgentSource::GlobalFile(location.global(name))); - result = result.merge(global.clone()); + result = result.merge(KdlAgent::from(global?.clone())); } resolved_agents.insert(name.to_string(), result); } ConfigLocation::Global(_) => { - let mut global_file = match KdlAgent::from_path(fs, name, location.global(name))? { - None => KdlAgent::new(name), + let mut global_file = match KdlAgentDoc::from_path(fs, name, location.global(name)) + { + None => KdlAgent::new(name.to_string()), Some(a) => { agent_sources.push(KdlAgentSource::GlobalFile(location.global(name))); - a + KdlAgent::from(a?) } }; if let Some(inline) = global_agents.get(name) { diff --git a/src/generator/merge.rs b/src/generator/merge.rs index 8e8329e..0828e5d 100644 --- a/src/generator/merge.rs +++ b/src/generator/merge.rs @@ -1,4 +1,4 @@ -use {super::*, crate::kdl::KdlAgent, std::collections::HashSet}; +use {super::*, crate::config::KdlAgent, std::collections::HashSet}; impl Generator { /// Resolve transitive inheritance chain for an agent @@ -10,7 +10,7 @@ impl Generator { visited: &mut HashSet, ) -> Result> { if visited.contains(&agent.name) { - return Err(color_eyre::eyre::eyre!( + return Err(crate::format_err!( "Circular inheritance detected: {} already in chain", agent.name )); @@ -18,12 +18,12 @@ impl Generator { visited.insert(agent.name.clone()); let mut chain = Vec::new(); - for parent_name in agent.inherits().iter() { + for parent_name in agent.inherits.iter() { let parent = self .resolved .agents .get(parent_name) - .ok_or_else(|| color_eyre::eyre::eyre!("Agent '{parent_name}' not found"))?; + .ok_or_else(|| crate::format_err!("Agent '{parent_name}' not found"))?; let parent_chain = self.resolve_transitive_inheritance(parent, visited)?; for p in parent_chain { @@ -54,9 +54,10 @@ impl Generator { let mut merged = agent.clone(); for parent_name in parents.iter().rev() { - let parent = self.resolved.agents.get(parent_name).ok_or_else(|| { - color_eyre::eyre::eyre!("Parent agent '{parent_name}' not found") - })?; + let parent = + self.resolved.agents.get(parent_name).ok_or_else(|| { + crate::format_err!("Parent agent '{parent_name}' not found") + })?; merged = merged.merge(parent.clone()); } @@ -94,50 +95,61 @@ mod tests { // Verify inheritance chain was resolved: dependabot -> aws-test -> base assert_eq!( - dependabot.description, - Some("I make life painful for developers".to_string()) + dependabot.description.clone().unwrap_or_default(), + "I make life painful for developers" ); // Should have prompt from aws-test - assert_eq!(dependabot.prompt, Some("you are an AWS expert".to_string())); + assert_eq!( + dependabot.prompt.clone().unwrap_or_default(), + "you are an AWS expert".to_string() + ); // Should have tools from base - let tools = dependabot.tools(); + let tools = &dependabot.tools; assert!(tools.contains("*")); // Should have allowed_tools merged from base and aws-test - let allowed = dependabot.allowed_tools(); + let allowed = &dependabot.allowed_tools; assert!(allowed.contains("read")); assert!(allowed.contains("knowledge")); - assert!(allowed.contains("@fetch")); + assert!(allowed.contains("fetch")); assert!(allowed.contains("@awsdocs")); // Should have resources from all three - let resources: Vec = dependabot.resources().map(|s| s.to_string()).collect(); + let resources = &dependabot.resources; assert!(resources.contains(&"file://README.md".to_string())); assert!(resources.contains(&"file://AGENTS.md".to_string())); assert!(resources.contains(&"file://.amazonq/rules/**/*.md".to_string())); // Should have hooks from all levels - let hooks = dependabot.hooks(); - assert!(hooks.contains_key(&crate::agent::hook::HookTrigger::AgentSpawn)); + let hooks = &dependabot.hook; + assert!( + !hooks + .hooks(&crate::agent::hook::HookTrigger::AgentSpawn) + .is_empty() + ); // Should have force permissions from dependabot overriding denies from base let shell = dependabot.get_tool_shell(); - assert!(shell.override_command.contains(&"git commit .*".into())); - assert!(shell.override_command.contains(&"git push .*".into())); + let overrides = &shell.overrides; + assert!(overrides.contains("git commit .*")); + assert!(overrides.contains("git push .*")); let read = dependabot.get_tool_read(); - assert!(read.override_path.contains(&".*Cargo.toml.*".into())); + let overrides = &read.overrides; + assert!(overrides.contains(".*Cargo.toml.*")); let write = dependabot.get_tool_write(); - assert!(write.override_path.contains(&".*Cargo.toml.*".into())); + let overrides = &write.overrides; + assert!(overrides.contains(".*Cargo.toml.*")); // Should have aws tool from aws-test let aws = dependabot.get_tool_aws(); - assert!(aws.allow.list.contains("ec2")); - assert!(aws.allow.list.contains("s3")); - assert!(aws.deny.list.contains("iam")); + + assert_eq!(2, aws.allows.len()); + assert!(aws.allows.contains("ec2")); + assert!(aws.allows.contains("s3")); // check try_from let results = generator.write_all(true).await?; diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 964bac8..f7d7b0d 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -2,10 +2,10 @@ use { crate::{ Result, agent::{Agent, ToolTarget}, - kdl::KdlAgent, + config::KdlAgent, os::Fs, }, - color_eyre::eyre::{Context, eyre}, + miette::{Context, IntoDiagnostic}, serde::Serialize, std::{ collections::{HashMap, HashSet}, @@ -36,34 +36,34 @@ impl AgentResult { match target { ToolTarget::Read => self .agent - .get_tool_read() - .override_path + .native_tool + .read + .overrides .iter() - .cloned() .map(|f| f.to_string()) .collect(), ToolTarget::Write => self .agent - .get_tool_write() - .override_path + .native_tool + .write + .overrides .iter() - .cloned() .map(|f| f.to_string()) .collect(), ToolTarget::Shell => self .agent - .get_tool_shell() - .override_command + .native_tool + .shell + .overrides .iter() - .cloned() .map(|f| f.to_string()) .collect(), _ => vec![], } } - pub fn resources(&self) -> Vec { - self.agent.resources().map(|s| s.to_string()).collect() + pub fn resources(&self) -> HashSet { + self.agent.resources.clone() } } @@ -155,7 +155,8 @@ impl Generator { self.fs .create_dir_all(&result.destination) .await - .with_context(|| { + .into_diagnostic() + .wrap_err_with(|| { format!( "failed to create directory {}", result.destination.display() @@ -168,9 +169,13 @@ impl Generator { .join(format!("{}.json", result.agent.name)); self.fs - .write(&out, serde_json::to_string_pretty(&result.kiro_agent)?) + .write( + &out, + serde_json::to_string_pretty(&result.kiro_agent).into_diagnostic()?, + ) .await - .with_context(|| format!("failed to write file {}", out.display()))?; + .into_diagnostic() + .wrap_err_with(|| format!("failed to write file {}", out.display()))?; } Ok(result) } diff --git a/src/kdl/agent.rs b/src/kdl/agent.rs deleted file mode 100644 index b2a1331..0000000 --- a/src/kdl/agent.rs +++ /dev/null @@ -1,244 +0,0 @@ -use { - super::{hook::HookPart, mcp::CustomToolConfigKdl}, - crate::{ - agent::{ - CustomToolConfig, - OriginalToolName, - hook::{Hook, HookTrigger}, - }, - kdl::native::{AwsTool, ExecuteShellTool, NativeTools, ReadTool, WriteTool}, - }, - color_eyre::eyre::WrapErr, - knuffel::Decode, - std::{ - collections::{HashMap, HashSet}, - fmt::{Debug, Display}, - hash::Hash, - }, -}; - -#[derive(Decode, Clone, Default, Debug)] -pub(super) struct Inherits { - #[knuffel(arguments, default)] - pub parents: HashSet, -} - -#[derive(Decode, Clone, Default, Debug)] -pub(super) struct Tools { - #[knuffel(arguments, default)] - pub tools: HashSet, -} - -#[derive(Decode, Clone, Default, Debug)] -pub(super) struct AllowedTools { - #[knuffel(arguments, default)] - pub allowed: HashSet, -} - -#[derive(Decode, Clone, Default, Debug)] -pub(super) struct Resource { - #[knuffel(argument)] - pub location: String, -} - -impl PartialEq for Resource { - fn eq(&self, other: &Self) -> bool { - self.location.eq(&other.location) - } -} - -impl Hash for Resource { - fn hash(&self, state: &mut H) { - self.location.hash(state); - } -} -impl Eq for Resource {} - -#[derive(Decode, Clone, Default, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub(super) struct ToolAliasKdl { - #[knuffel(argument)] - from: String, - #[knuffel(argument)] - to: String, -} - -/// Raw JSON tool settings for forward compatibility. -/// -/// Allows users to configure tool settings not yet supported by kg's schema. -/// The JSON must be a valid object (not array or primitive). -/// -/// See https://kiro.dev/docs/cli/custom-agents/configuration-reference/#toolssettings-field -#[derive(Decode, Clone, Debug)] -pub struct ToolSetting { - #[knuffel(argument)] - name: String, - #[knuffel(child, unwrap(argument))] - json: String, -} - -impl ToolSetting { - fn to_value(&self) -> crate::Result<(String, serde_json::Value)> { - let v: serde_json::Value = serde_json::from_str(&self.json) - .wrap_err_with(|| format!("Failed to parse JSON for tool-setting '{}'", self.name))?; - - if !v.is_object() { - return Err(color_eyre::eyre::eyre!( - "tool-setting '{}' must be a JSON object, got: {}", - self.name, - v - )); - } - - Ok((self.name.clone(), v)) - } -} - -#[derive(Decode, Clone, Default)] -pub struct KdlAgent { - /// Name of the agent - #[knuffel(argument)] - pub name: String, - /// Do not generate JSON Kiro agent, this is a "template" agent - #[knuffel(property, default)] - pub template: Option, - #[knuffel(child, unwrap(argument))] - pub description: Option, - #[knuffel(child, default)] - pub(super) inherits: Inherits, - /// The intention for this field is to provide high level context to the - /// agent. This should be seen as the same category of context as a system - /// prompt. - #[knuffel(child, unwrap(argument))] - pub prompt: Option, - /// Files to include in the agent's context - #[knuffel(children(name = "resource"))] - pub(super) resources: HashSet, - #[knuffel(child, default, unwrap(argument))] - pub include_mcp_json: Option, - /// List of tools the agent can see. Use \"@{MCP_SERVER_NAME}/tool_name\" to - /// specify tools from mcp servers. To include all tools from a server, - /// use \"@{MCP_SERVER_NAME}\" - #[knuffel(child, default)] - pub(super) tools: Tools, - /// List of tools the agent is explicitly allowed to use - #[knuffel(child, default)] - pub(super) allowed_tools: AllowedTools, - /// The model ID to use for this agent. If not specified, uses the default - /// model. - #[knuffel(child, unwrap(argument))] - pub model: Option, - /// Commands to run when a chat session is created - #[knuffel(child)] - pub(super) hook: Option, - #[knuffel(children(name = "mcp"), default)] - pub(super) mcp: Vec, - #[knuffel(children(name = "alias"), default)] - pub(super) tool_aliases: HashSet, - /// Tools builtin to kiro - #[knuffel(child, default)] - pub native_tool: NativeTools, - - #[knuffel(children(name = "tool-setting"), default)] - pub(super) tool_settings: Vec, -} - -impl Debug for KdlAgent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name) - } -} - -impl Display for KdlAgent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name) - } -} - -impl KdlAgent { - pub fn new(name: impl AsRef) -> Self { - Self { - name: name.as_ref().to_string(), - ..Default::default() - } - } - - pub fn is_template(&self) -> bool { - self.template.is_some_and(|f| f) - } - - pub fn include_mcp_json(&self) -> bool { - self.include_mcp_json.is_some_and(|f| f) - } - - pub fn get_tool_aws(&self) -> &AwsTool { - &self.native_tool.aws - } - - pub fn get_tool_read(&self) -> &ReadTool { - &self.native_tool.read - } - - pub fn get_tool_write(&self) -> &WriteTool { - &self.native_tool.write - } - - pub fn get_tool_shell(&self) -> &ExecuteShellTool { - &self.native_tool.shell - } - - pub fn tool_aliases(&self) -> HashMap { - self.tool_aliases - .iter() - .map(|m| (OriginalToolName(m.from.clone()), m.to.clone())) - .collect() - } - - pub fn hooks(&self) -> HashMap> { - match &self.hook { - None => HashMap::new(), - Some(h) => h.triggers(), - } - } - - pub fn allowed_tools(&self) -> &HashSet { - &self.allowed_tools.allowed - } - - pub fn tools(&self) -> &HashSet { - &self.tools.tools - } - - pub fn inherits(&self) -> &HashSet { - &self.inherits.parents - } - - pub fn resources(&self) -> impl Iterator { - self.resources.iter().map(|r| r.location.as_str()) - } - - pub fn mcp_servers(&self) -> HashMap { - self.mcp - .iter() - .map(|m| (m.name.clone(), m.into())) - .collect() - } - - /// Parse raw JSON tool settings into a map. - /// - /// This allows users to configure tools not yet supported by kg's schema. - pub fn extra_tool_settings(&self) -> crate::Result> { - let mut result = HashMap::new(); - for setting in &self.tool_settings { - let (name, value) = setting.to_value()?; - if result.contains_key(&name) { - return Err(color_eyre::eyre::eyre!( - "[{self}] - Duplicate tool-setting '{}' found. Each tool-setting name must be \ - unique.", - name - )); - } - result.insert(name, value); - } - Ok(result) - } -} diff --git a/src/kdl/agent_file.rs b/src/kdl/agent_file.rs deleted file mode 100644 index 0f6edc5..0000000 --- a/src/kdl/agent_file.rs +++ /dev/null @@ -1,78 +0,0 @@ -use { - super::{agent::*, hook::HookPart, mcp::CustomToolConfigKdl, native::NativeTools}, - crate::os::Fs, - color_eyre::eyre::eyre, - knuffel::{Decode, parse}, - std::{collections::HashSet, path::Path}, -}; - -#[derive(Decode, Clone, Debug)] -pub struct KdlAgentFileSource { - #[knuffel(child, unwrap(argument))] - pub description: Option, - #[knuffel(child, unwrap(argument))] - pub prompt: Option, - #[knuffel(children(name = "resource"))] - pub(super) resources: HashSet, - #[knuffel(child, default, unwrap(argument))] - pub include_mcp_json: Option, - #[knuffel(child, default)] - pub(super) tools: Tools, - #[knuffel(child, default)] - pub(super) allowed_tools: AllowedTools, - #[knuffel(child, unwrap(argument))] - pub model: Option, - #[knuffel(child)] - pub(super) hook: Option, - #[knuffel(children(name = "mcp"), default)] - pub(super) mcp: Vec, - #[knuffel(children(name = "alias"), default)] - pub(super) tool_aliases: HashSet, - #[knuffel(child, default)] - pub(super) native_tool: NativeTools, - #[knuffel(children(name = "tool-setting"), default)] - pub(super) tool_settings: Vec, -} - -impl KdlAgent { - pub fn from_path( - fs: &Fs, - name: impl AsRef, - path: impl AsRef, - ) -> crate::Result> { - if !fs.exists(&path) { - return Ok(None); - } - - let content = fs.read_to_string_sync(&path)?; - let path_str = format!("{}", path.as_ref().display()); - let agent: KdlAgentFileSource = match parse(&path_str, &content) { - Ok(a) => a, - Err(e) => { - eprintln!("{:?}", miette::Report::new(e)); - return Err(eyre!("failed to parse agent file")); - } - }; - Ok(Some(Self::from_file_source(name, agent))) - } - - pub fn from_file_source(name: impl AsRef, file_source: KdlAgentFileSource) -> Self { - Self { - name: name.as_ref().to_string(), - description: file_source.description, - template: None, - inherits: Inherits::default(), - prompt: file_source.prompt, - resources: file_source.resources, - include_mcp_json: file_source.include_mcp_json, - tools: file_source.tools, - allowed_tools: file_source.allowed_tools, - model: file_source.model, - hook: file_source.hook, - mcp: file_source.mcp, - tool_aliases: file_source.tool_aliases, - native_tool: file_source.native_tool, - tool_settings: file_source.tool_settings, - } - } -} diff --git a/src/kdl/hook.rs b/src/kdl/hook.rs deleted file mode 100644 index 92edaac..0000000 --- a/src/kdl/hook.rs +++ /dev/null @@ -1,348 +0,0 @@ -use { - crate::agent::hook::{Hook, HookTrigger}, - knuffel::Decode, - std::collections::HashMap, -}; - -macro_rules! define_hook { - ($name:ident) => { - #[derive(Decode, Default, Clone, Debug, PartialEq, Eq)] - pub(super) struct $name { - /// The command to run when the hook is triggered - #[knuffel(argument)] - pub name: String, - /// The command to run when the hook is triggered - #[knuffel(child, default, unwrap(argument))] - pub command: String, - - /// Max time the hook can run before it throws a timeout error - #[knuffel(child, default, unwrap(argument))] - pub timeout_ms: u64, - - /// Max output size of the hook before it is truncated - #[knuffel(child, default, unwrap(argument))] - pub max_output_size: usize, - - /// How long the hook output is cached before it will be executed again - #[knuffel(child, default, unwrap(argument))] - pub cache_ttl_seconds: u64, - - /// Optional glob matcher for hook - /// Currently used for matching tool name of PreToolUse and PostToolUse hook - #[knuffel(child, default, unwrap(argument))] - pub matcher: Option, - } - - impl From<$name> for Hook { - fn from(value: $name) -> Hook { - Hook { - command: value.command, - timeout_ms: value.timeout_ms, - max_output_size: value.max_output_size, - cache_ttl_seconds: value.cache_ttl_seconds, - matcher: value.matcher, - } - } - } - - impl $name { - fn merge(mut self, o: $name) -> $name { - if self.cache_ttl_seconds == 0 && o.cache_ttl_seconds > 0 { - self.cache_ttl_seconds = o.cache_ttl_seconds; - } - if self.command.is_empty() { - self.command = o.command.clone(); - } - if self.max_output_size == 0 && o.max_output_size > 0 { - self.max_output_size = o.max_output_size; - } - if self.timeout_ms == 0 && o.timeout_ms > 0 { - self.timeout_ms = o.timeout_ms; - } - if self.matcher.is_none() - && let Some(m) = &o.matcher - { - self.matcher = Some(m.clone()); - } - self - } - } - }; -} - -define_hook!(HookAgentSpawn); -define_hook!(HookUserPromptSubmit); -define_hook!(HookPreToolUse); -define_hook!(HookPostToolUse); -define_hook!(HookStop); - -#[derive(Decode, Clone, Default, Debug, PartialEq, Eq)] -pub(super) struct HookPart { - #[knuffel(children(name = "agent-spawn"), default)] - pub agent_spawn: Vec, - #[knuffel(children(name = "user-prompt-submit"), default)] - pub user_prompt_submit: Vec, - #[knuffel(children(name = "pre-tool-use"), default)] - pub pre_tool_use: Vec, - #[knuffel(children(name = "post-tool-use"), default)] - pub post_tool_use: Vec, - #[knuffel(children(name = "stop"), default)] - pub stop: Vec, -} - -impl HookPart { - pub fn merge(mut self, other: Self) -> Self { - match (self.agent_spawn.is_empty(), other.agent_spawn.is_empty()) { - (false, false) => { - let mut hooks = Vec::with_capacity(self.agent_spawn.len()); - for h in self.agent_spawn { - if let Some(o) = other.agent_spawn.iter().find(|i| i.name == h.name) { - hooks.push(h.merge(o.clone())); - } else { - hooks.push(h); - } - } - self.agent_spawn = hooks; - for o in other.agent_spawn.into_iter() { - if !self.agent_spawn.iter().any(|h| h.name == o.name) { - self.agent_spawn.push(o); - } - } - } - (true, false) => self.agent_spawn = other.agent_spawn, - _ => {} - }; - - match ( - self.user_prompt_submit.is_empty(), - other.user_prompt_submit.is_empty(), - ) { - (false, false) => { - let mut hooks = Vec::with_capacity(self.user_prompt_submit.len()); - for h in self.user_prompt_submit { - if let Some(o) = other.user_prompt_submit.iter().find(|i| i.name.eq(&h.name)) { - hooks.push(h.merge(o.clone())); - } else { - hooks.push(h); - } - } - self.user_prompt_submit = hooks; - for o in other.user_prompt_submit.into_iter() { - if !self.user_prompt_submit.iter().any(|h| h.name == o.name) { - self.user_prompt_submit.push(o); - } - } - } - (true, false) => self.user_prompt_submit = other.user_prompt_submit, - _ => {} - }; - - match (self.pre_tool_use.is_empty(), other.pre_tool_use.is_empty()) { - (false, false) => { - let mut hooks = Vec::with_capacity(self.pre_tool_use.len()); - for h in self.pre_tool_use { - if let Some(o) = other.pre_tool_use.iter().find(|i| i.name.eq(&h.name)) { - hooks.push(h.merge(o.clone())); - } else { - hooks.push(h); - } - } - self.pre_tool_use = hooks; - for o in other.pre_tool_use.into_iter() { - if !self.pre_tool_use.iter().any(|h| h.name == o.name) { - self.pre_tool_use.push(o); - } - } - } - (true, false) => self.pre_tool_use = other.pre_tool_use, - _ => {} - }; - - match ( - self.post_tool_use.is_empty(), - other.post_tool_use.is_empty(), - ) { - (false, false) => { - let mut hooks = Vec::with_capacity(self.post_tool_use.len()); - for h in self.post_tool_use { - if let Some(o) = other.post_tool_use.iter().find(|i| i.name.eq(&h.name)) { - hooks.push(h.merge(o.clone())); - } else { - hooks.push(h); - } - } - self.post_tool_use = hooks; - for o in other.post_tool_use.into_iter() { - if !self.post_tool_use.iter().any(|h| h.name == o.name) { - self.post_tool_use.push(o); - } - } - } - (true, false) => self.post_tool_use = other.post_tool_use, - _ => {} - }; - - match (self.stop.is_empty(), other.stop.is_empty()) { - (false, false) => { - let mut hooks = Vec::with_capacity(self.stop.len()); - for h in self.stop { - if let Some(o) = other.stop.iter().find(|i| i.name.eq(&h.name)) { - hooks.push(h.merge(o.clone())); - } else { - hooks.push(h); - } - } - self.stop = hooks; - for o in other.stop.into_iter() { - if !self.stop.iter().any(|h| h.name == o.name) { - self.stop.push(o); - } - } - } - (true, false) => self.stop = other.stop, - _ => {} - }; - self - } - - pub fn triggers(&self) -> HashMap> { - let trigger: Vec = enum_iterator::all::().collect(); - let mut hooks: HashMap> = HashMap::new(); - for t in trigger { - match t { - HookTrigger::AgentSpawn => { - hooks.insert( - t, - self.agent_spawn - .iter() - .map(|h| Hook::from(h.clone())) - .collect(), - ); - } - HookTrigger::UserPromptSubmit => { - hooks.insert( - t, - self.user_prompt_submit - .iter() - .map(|h| Hook::from(h.clone())) - .collect(), - ); - } - HookTrigger::PreToolUse => { - hooks.insert( - t, - self.pre_tool_use - .iter() - .map(|h| Hook::from(h.clone())) - .collect(), - ); - } - HookTrigger::PostToolUse => { - hooks.insert( - t, - self.post_tool_use - .iter() - .map(|h| Hook::from(h.clone())) - .collect(), - ); - } - HookTrigger::Stop => { - hooks.insert(t, self.stop.iter().map(|h| Hook::from(h.clone())).collect()); - } - }; - } - hooks - } -} - -#[cfg(test)] -mod tests { - use {super::*, crate::Result, std::time::Duration}; - - macro_rules! rando_hook { - ($name:ident) => { - impl $name { - fn rando() -> $name { - let value = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - Self { - name: format!("$name-{value}"), - command: format!("{value}"), - timeout_ms: value, - max_output_size: 0, - cache_ttl_seconds: value, - matcher: Some(format!("{value}")), - } - } - } - }; - } - rando_hook!(HookAgentSpawn); - rando_hook!(HookUserPromptSubmit); - rando_hook!(HookPreToolUse); - rando_hook!(HookPostToolUse); - rando_hook!(HookStop); - - impl HookPart { - pub fn randomize() -> Self { - Self { - agent_spawn: vec![HookAgentSpawn::rando()], - user_prompt_submit: vec![HookUserPromptSubmit::rando()], - pre_tool_use: vec![HookPreToolUse::rando()], - post_tool_use: vec![HookPostToolUse::rando()], - stop: vec![HookStop::rando()], - } - } - } - - #[test_log::test] - pub fn test_hooks_empty() -> Result<()> { - let child = HookPart::default(); - let parent = HookPart::default(); - let merged = child.merge(parent); - - assert!(merged.agent_spawn.is_empty()); - assert!(merged.user_prompt_submit.is_empty()); - assert!(merged.pre_tool_use.is_empty()); - assert!(merged.post_tool_use.is_empty()); - assert!(merged.stop.is_empty()); - Ok(()) - } - - #[test_log::test] - pub fn test_hooks_empty_child() -> Result<()> { - let child = HookPart::default(); - let parent = HookPart::randomize(); - let before = parent.clone(); - let merged = child.merge(parent); - - assert_eq!(merged, before); - Ok(()) - } - - #[test_log::test] - pub fn test_hooks_no_merge() -> Result<()> { - let child = HookPart::randomize(); - let parent = HookPart::randomize(); - let before = child.clone(); - let merged = child.merge(parent); - assert_eq!(merged, before); - Ok(()) - } - - #[test_log::test] - pub fn test_hooks_merge_parent() -> Result<()> { - let child = HookPart::randomize(); - std::thread::sleep(Duration::from_millis(1300)); // see randomize function - let parent = HookPart::randomize(); - let merged = child.merge(parent); - assert_eq!(merged.agent_spawn.len(), 2); - assert_eq!(merged.user_prompt_submit.len(), 2); - assert_eq!(merged.pre_tool_use.len(), 2); - assert_eq!(merged.post_tool_use.len(), 2); - assert_eq!(merged.stop.len(), 2); - Ok(()) - } -} diff --git a/src/kdl/mcp.rs b/src/kdl/mcp.rs deleted file mode 100644 index 11d387e..0000000 --- a/src/kdl/mcp.rs +++ /dev/null @@ -1,105 +0,0 @@ -use {crate::agent::CustomToolConfig, knuffel::Decode}; - -#[derive(Decode, Clone, Debug)] -struct EnvVar { - #[knuffel(argument)] - key: String, - #[knuffel(argument)] - value: String, -} - -#[derive(Decode, Clone, Debug)] -struct Header { - #[knuffel(argument)] - key: String, - #[knuffel(argument)] - value: String, -} - -#[derive(Decode, Default, Clone, Debug)] -struct ToolArgs { - #[knuffel(arguments, default)] - args: Vec, -} - -#[derive(Decode, Clone, Debug, Eq, PartialEq)] -pub struct OAuthConfig { - /// Custom redirect URI for OAuth flow (e.g., "127.0.0.1:7778") - /// If not specified, a random available port will be assigned by the OS - #[knuffel(child, unwrap(argument))] - pub redirect_uri: String, -} - -#[derive(Decode, Clone, Debug)] -pub struct CustomToolConfigKdl { - #[knuffel(argument)] - pub name: String, - - #[knuffel(child, unwrap(argument))] - pub url: Option, - - #[knuffel(child, unwrap(argument))] - pub command: Option, - - #[knuffel(child)] - pub oauth: Option, - - #[knuffel(child, default)] - args: ToolArgs, - - #[knuffel(children(name = "env"))] - env_vars: Vec, - - #[knuffel(children(name = "header"))] - headers: Vec
, - - #[knuffel(child, default, unwrap(argument))] - pub timeout: u64, - - #[knuffel(child, default, unwrap(argument))] - pub disabled: bool, -} - -impl From for CustomToolConfig { - fn from(value: CustomToolConfigKdl) -> Self { - let command = value.command.unwrap_or_default(); - let oauth = value.oauth.map(|o| crate::agent::OAuthConfig { - redirect_uri: Some(o.redirect_uri), - }); - let url = value.url.unwrap_or_default(); - - Self { - url, - r#type: if command.is_empty() { - crate::agent::TransportType::Stdio - } else { - crate::agent::TransportType::Http - }, - command, - args: value.args.args, - oauth, - timeout: if value.timeout == 0 { - crate::agent::tool_default_timeout() - } else { - value.timeout - }, - disabled: value.disabled, - headers: value - .headers - .into_iter() - .map(|h| (h.key, h.value)) - .collect(), - env: value - .env_vars - .into_iter() - .map(|e| (e.key, e.value)) - .collect(), - } - } -} - -impl From<&CustomToolConfigKdl> for CustomToolConfig { - fn from(value: &CustomToolConfigKdl) -> Self { - value.clone().into() - } -} diff --git a/src/kdl/merge.rs b/src/kdl/merge.rs deleted file mode 100644 index 7586fc3..0000000 --- a/src/kdl/merge.rs +++ /dev/null @@ -1,206 +0,0 @@ -use super::*; - -impl KdlAgent { - pub fn merge(mut self, other: KdlAgent) -> Self { - // Child wins for explicit values - self.include_mcp_json = self.include_mcp_json.or(other.include_mcp_json); - self.template = self.template.or(other.template); - self.description = self.description.or(other.description); - self.prompt = self.prompt.or(other.prompt); - self.model = self.model.or(other.model); - - // Collections are extended (merged) - self.resources.extend(other.resources); - self.tools.tools.extend(other.tools.tools); - self.allowed_tools - .allowed - .extend(other.allowed_tools.allowed); - self.tool_aliases.extend(other.tool_aliases); - self.mcp.extend(other.mcp); - self.inherits.parents.extend(other.inherits.parents); - self.tool_settings.extend(other.tool_settings); - - // Hooks are deep merged - self.hook = match (self.hook, other.hook) { - (None, Some(h)) => Some(h), - (Some(a), Some(b)) => Some(a.merge(b)), - (Some(a), None) => Some(a), - (None, None) => None, - }; - - // Native tools are deep merged - self.native_tool = self.native_tool.merge(other.native_tool); - - self - } -} - -#[cfg(test)] -mod tests { - use {super::*, crate::agent::hook::HookTrigger, color_eyre::eyre::eyre, knuffel::parse}; - - #[test_log::test] - fn test_agent_merge() -> crate::Result<()> { - let kdl_agents = r#" - agent "child" template=false { - description "I am a child" - resource "file://child.md" - resource "file://README.md" - inherits "parent" - include-mcp-json true - tools "@awsdocs" "shell" - native-tool { - write { - override "Cargo.lock" - } - shell { - override "git push .*" - } - } - hook { - agent-spawn "spawn" { - command "echo i have spawned" - max-output-size 9000 - cache-ttl-seconds 2 - } - } - alias "execute_bash" "shell" - } - agent "parent" template=true { - description "I am parent" - resource "file://parent.md" - resource "file://README.md" - tools "web_search" "shell" - prompt "i tell you what to do" - model "claude" - allowed-tools "write" - alias "execute_bash" "shell" - alias "fs_read" "read" - native-tool { - read { - allow "./src/*" "./scripts/**" - deny "Cargo.lock" - } - write { - allow "./src/*" "./scripts/**" - deny "Cargo.lock" - } - - shell { - allow "git status .*" "git pull .*" - deny "git push .*" - } - } - hook { - agent-spawn "spawn" { - timeout-ms 1111 - } - user-prompt-submit "submit" { - command "echo user submitted" - timeout-ms 1000 - } - pre-tool-use "pre" { - command "echo before tool" - matcher "git.*" - } - post-tool-use "post" { - command "echo after tool" - } - stop "stop" { - command "echo stopped" - } - } - } - "#; - - let config: GeneratorConfig = match parse("example.kdl", kdl_agents) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", miette::Report::new(e)); - return Err(eyre!("failed to parse {kdl_agents}")); - } - }; - assert_eq!(config.agents.len(), 2); - let child = config - .agents - .iter() - .find(|a| a.name == "child") - .unwrap() - .clone(); - let parent = config - .agents - .iter() - .find(|a| a.name == "parent") - .unwrap() - .clone(); - let merged = child.merge(parent); - assert!(merged.description.is_some()); - let d = merged.description.clone().unwrap(); - assert_eq!(d, "I am a child"); - - assert_eq!(merged.resources.len(), 3); - assert!(!merged.is_template()); - assert!(merged.include_mcp_json()); - - assert_eq!(merged.inherits.parents.len(), 1); - assert!(merged.inherits.parents.contains("parent")); - - assert_eq!(merged.prompt, Some("i tell you what to do".to_string())); - let tools = merged.tools(); - assert_eq!(tools.len(), 3); - assert!(tools.contains("@awsdocs")); - assert!(tools.contains("shell")); - assert!(tools.contains("web_search")); - - assert_eq!(merged.model, Some("claude".to_string())); - - let allowed_tools = merged.allowed_tools(); - assert_eq!(allowed_tools.len(), 1); - assert!(allowed_tools.contains("write")); - - let hooks = merged.hooks(); - assert!(!hooks.is_empty()); - let h = hooks.get(&HookTrigger::AgentSpawn); - assert!(h.is_some()); - let h = h.unwrap(); - assert!(!h.is_empty()); - assert_eq!(h[0].timeout_ms, 1111); - assert_eq!(h[0].command, "echo i have spawned"); - - let h = hooks.get(&HookTrigger::UserPromptSubmit); - assert!(h.is_some()); - let h = h.unwrap(); - assert!(!h.is_empty()); - assert_eq!(h[0].command, "echo user submitted"); - assert_eq!(h[0].timeout_ms, 1000); - - let alias = merged.tool_aliases(); - assert_eq!(alias.len(), 2); - assert!(alias.contains_key("fs_read")); - assert!(alias.contains_key("execute_bash")); - - let tool = merged.get_tool_write(); - assert!(tool.override_path.contains(&"Cargo.lock".into())); - assert_eq!(tool.allow.list.len(), 2); - assert_eq!(tool.override_path.len(), 1); - assert_eq!(tool.deny.list.len(), 1); - - let tool = merged.get_tool_read(); - assert_eq!(tool.allow.list.len(), 2); - assert_eq!(tool.override_path.len(), 0); - assert_eq!(tool.deny.list.len(), 1); - - let tool = merged.get_tool_shell(); - assert_eq!(tool.allow.list.len(), 2); - assert_eq!(tool.override_command.len(), 1); - assert_eq!(tool.deny.list.len(), 1); - - let tool = merged.get_tool_aws(); - assert!(tool.allow.list.is_empty()); - assert!(tool.deny.list.is_empty()); - - assert_eq!("child", format!("{merged}")); - assert_eq!("child", format!("{merged:?}")); - Ok(()) - } -} diff --git a/src/kdl/mod.rs b/src/kdl/mod.rs deleted file mode 100644 index b3eb23e..0000000 --- a/src/kdl/mod.rs +++ /dev/null @@ -1,294 +0,0 @@ -mod agent; -mod agent_file; -mod hook; -mod mcp; -mod merge; -mod native; -use std::{collections::HashSet, fmt::Debug}; - -pub use agent::KdlAgent; - -#[derive(knuffel::Decode, Default)] -pub struct GeneratorConfig { - #[knuffel(children(name = "agent"))] - pub agents: Vec, -} - -impl Debug for GeneratorConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "agents={}", self.agents.len()) - } -} - -impl GeneratorConfig { - pub fn names(&self) -> HashSet { - self.agents.iter().map(|a| a.name.clone()).collect() - } - - pub fn get(&self, name: impl AsRef) -> Option<&KdlAgent> { - self.agents.iter().find(|a| a.name.eq(name.as_ref())) - } -} - -#[cfg(test)] -mod tests { - use { - super::*, - crate::{agent::hook::HookTrigger, kdl::agent_file::KdlAgentFileSource}, - color_eyre::eyre::eyre, - knuffel::parse, - }; - - #[test_log::test] - fn test_agent_decoding() -> crate::Result<()> { - let kdl_agents = r#" - agent "test" { - inherits "parent" - description "This is a test agent" - prompt "Generate a test prompt" - resource "file://resource.md" - resource "file://README.md" - include-mcp-json true - tools "*" - - allowed-tools "@awsdocs" - hook { - agent-spawn "spawn" { - command "echo i have spawned" - timeout-ms 1000 - max-output-size 9000 - cache-ttl-seconds 2 - } - user-prompt-submit "submit" { - command "echo user submitted" - } - pre-tool-use "pre" { - command "echo before tool" - matcher "git.*" - } - post-tool-use "post" { - command "echo after tool" - } - stop "stop" { - command "echo stopped" - } - } - - mcp "awsdocs" { - command "aws-docs" - args "--verbose" "--config=/path" - env "RUST_LOG" "debug" - env "PATH" "/usr/bin" - header "Authorization" "Bearer token" - timeout 5000 - oauth { - redirect-uri "127.0.0.1:7778" - } - } - - alias "execute_bash" "shell" - - native-tool { - write { - allow "./src/*" "./scripts/**" - deny "Cargo.lock" - override "/tmp" - override "/var/log" - } - shell deny-by-default=true { - allow "git status .*" - deny "git push .*" - override "git pull .*" - } - } - - tool-setting "@git/status" { - json "{ \"git_user\": \"$GIT_USER\" }" - } - } - "#; - - let config: GeneratorConfig = match parse("example.kdl", kdl_agents) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", miette::Report::new(e)); - return Err(eyre!("failed to parse {kdl_agents}")); - } - }; - assert_eq!(config.agents.len(), 1); - let agent = config.agents[0].clone(); - assert_eq!(agent.name, "test"); - assert!(agent.model.is_none()); - assert!(!agent.is_template()); - let inherits = agent.inherits(); - assert_eq!(inherits.len(), 1); - assert_eq!(inherits.iter().next().unwrap(), "parent"); - assert!(agent.description.is_some()); - assert!(agent.prompt.is_some()); - assert!(agent.include_mcp_json()); - let tools = agent.tools(); - assert_eq!(tools.len(), 1); - assert_eq!(tools.iter().next().unwrap(), "*"); - let resources: Vec = agent.resources().map(|s| s.to_string()).collect(); - assert_eq!(resources.len(), 2); - assert!(resources.contains(&"file://resource.md".to_string())); - assert!(resources.contains(&"file://README.md".to_string())); - - let hooks = agent.hooks(); - assert!(!hooks.is_empty()); - let hook = hooks.get(&HookTrigger::AgentSpawn); - assert!(hook.is_some()); - assert_eq!(hook.unwrap()[0].command, "echo i have spawned"); - - assert!(hooks.contains_key(&HookTrigger::PreToolUse)); - assert!(hooks.contains_key(&HookTrigger::PostToolUse)); - assert!(hooks.contains_key(&HookTrigger::Stop)); - assert!(hooks.contains_key(&HookTrigger::UserPromptSubmit)); - - let allowed = agent.allowed_tools(); - assert_eq!(allowed.len(), 1); - assert_eq!(allowed.iter().next().unwrap(), "@awsdocs"); - - let mcp = agent.mcp_servers(); - assert_eq!(mcp.len(), 1); - assert!(mcp.contains_key("awsdocs")); - let aws_docs = mcp.get("awsdocs").unwrap(); - assert_eq!(aws_docs.command, "aws-docs"); - assert_eq!(aws_docs.args, vec!["--verbose", "--config=/path"]); - assert!(!aws_docs.disabled); - assert_eq!(aws_docs.headers.len(), 1); - assert_eq!(aws_docs.env.len(), 2); - assert_eq!(aws_docs.timeout, 5000); - assert!(aws_docs.oauth.is_some()); - - assert_eq!(agent.tool_aliases().len(), 1); - - let extra = agent.extra_tool_settings()?; - assert_eq!(extra.len(), 1); - assert!(extra.contains_key("@git/status")); - let git_status = extra.get("@git/status").unwrap(); - assert!(git_status.is_object()); - assert_eq!(git_status["git_user"], "$GIT_USER"); - - Ok(()) - } - - #[test_log::test] - fn test_agent_empty() -> crate::Result<()> { - let kdl_agents = r#" - agent "test" template=true { - } - "#; - - let config: GeneratorConfig = match parse("example.kdl", kdl_agents) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", miette::Report::new(e)); - return Err(eyre!("failed to parse {kdl_agents}")); - } - }; - assert!(!format!("{config:?}").is_empty()); - assert_eq!(config.agents.len(), 1); - let agent = config.agents[0].clone(); - assert_eq!(agent.name, "test"); - assert!(agent.model.is_none()); - assert!(agent.is_template()); - - Ok(()) - } - - #[test_log::test] - fn test_agent_file_source() -> crate::Result<()> { - let kdl_agent_file_source = r#" - description "agent from file" - prompt "Generate a test prompt" - resource "file://resource.md" - resource "file://README.md" - include-mcp-json true - tools "*" - - allowed-tools "@awsdocs" - hook { - agent-spawn "spawn" { - command "echo i have spawned" - timeout-ms 1000 - max-output-size 9000 - cache-ttl-seconds 2 - } - user-prompt-submit "submit" { - command "echo user submitted" - } - pre-tool-use "pre" { - command "echo before tool" - matcher "git.*" - } - post-tool-use "post" { - command "echo after tool" - } - stop "stop" { - command "echo stopped" - } - } - - mcp "awsdocs" { - command "aws-docs" - args "--verbose" "--config=/path" - env "RUST_LOG" "debug" - env "PATH" "/usr/bin" - header "Authorization" "Bearer token" - timeout 5000 - oauth { - redirect-uri "127.0.0.1:7778" - } - } - - alias "execute_bash" "shell" - - native-tool { - write { - allow "./src/*" "./scripts/**" - deny "Cargo.lock" - override "/tmp" - override "/var/log" - } - shell deny-by-default=true { - allow "git status .*" - deny "git push .*" - override "git pull .*" - } - } - "#; - - let agent: KdlAgentFileSource = match parse("example.kdl", kdl_agent_file_source) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", miette::Report::new(e)); - return Err(eyre!("failed to parse {kdl_agent_file_source}")); - } - }; - - assert_eq!(agent.description.unwrap_or_default(), "agent from file"); - Ok(()) - } - - #[test_log::test] - fn test_tool_setting_invalid_json() -> crate::Result<()> { - let kdl = r#" - agent "test" { - tool-setting "bad" { - json "{ invalid json }" - } - } - "#; - let config: GeneratorConfig = parse("test.kdl", kdl)?; - let result = config.agents[0].extra_tool_settings(); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to parse JSON") - ); - Ok(()) - } -} diff --git a/src/kdl/native.rs b/src/kdl/native.rs deleted file mode 100644 index 6ac395d..0000000 --- a/src/kdl/native.rs +++ /dev/null @@ -1,480 +0,0 @@ -use { - crate::agent::{ - AwsTool as KiroAwsTool, - ExecuteShellTool as KiroShellTool, - ReadTool as KiroReadTool, - WriteTool as KiroWriteTool, - }, - knuffel::Decode, - std::{collections::HashSet, fmt::Display}, -}; - -#[derive(Decode, Debug, Clone, Default, PartialEq, Eq)] -pub struct GenericList { - #[knuffel(arguments)] - pub list: HashSet, -} - -impl From<&'static str> for GenericList { - fn from(value: &'static str) -> Self { - Self { - list: HashSet::from_iter(vec![value.to_string()]), - } - } -} - -impl FromIterator<&'static str> for GenericList { - fn from_iter>(iter: T) -> Self { - Self { - list: HashSet::from_iter(iter.into_iter().map(|f| f.to_string())), - } - } -} - -#[derive(Decode, Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Override { - #[knuffel(argument)] - pub path: String, -} - -impl Display for Override { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.path) - } -} - -impl From<&str> for Override { - fn from(value: &str) -> Self { - Self { - path: value.to_string(), - } - } -} - -#[derive(Decode, Clone, Debug, Default, PartialEq, Eq)] -pub struct WriteTool { - #[knuffel(child, default)] - pub allow: GenericList, - #[knuffel(child, default)] - pub deny: GenericList, - #[knuffel(children(name = "override"))] - pub override_path: HashSet, -} - -impl WriteTool { - fn merge(mut self, other: Self) -> Self { - self.allow.list.extend(other.allow.list); - self.deny.list.extend(other.deny.list); - self.override_path.extend(other.override_path); - self - } -} - -#[derive(Decode, Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadTool { - #[knuffel(child, default)] - pub allow: GenericList, - #[knuffel(child, default)] - pub deny: GenericList, - #[knuffel(children(name = "override"))] - pub override_path: HashSet, -} - -impl ReadTool { - fn merge(mut self, other: Self) -> Self { - self.allow.list.extend(other.allow.list); - self.deny.list.extend(other.deny.list); - self.override_path.extend(other.override_path); - self - } -} - -#[derive(Decode, Debug, Default, Clone, PartialEq, Eq)] -pub struct AwsTool { - #[knuffel(property)] - pub disable_auto_readonly: Option, - #[knuffel(child, default)] - pub allow: GenericList, - #[knuffel(child, default)] - pub deny: GenericList, -} - -impl AwsTool { - fn merge(mut self, other: Self) -> Self { - self.disable_auto_readonly = self.disable_auto_readonly.or(other.disable_auto_readonly); - self.allow.list.extend(other.allow.list); - self.deny.list.extend(other.deny.list); - self - } -} - -#[derive(Decode, Debug, Clone, Default, PartialEq, Eq)] -pub struct ExecuteShellTool { - #[knuffel(child, default)] - pub allow: GenericList, - #[knuffel(child, default)] - pub deny: GenericList, - #[knuffel(property)] - pub deny_by_default: Option, - #[knuffel(property)] - pub disable_auto_readonly: Option, - #[knuffel(children(name = "override"))] - pub override_command: HashSet, -} - -impl ExecuteShellTool { - fn merge(mut self, other: Self) -> Self { - self.allow.list.extend(other.allow.list); - self.deny.list.extend(other.deny.list); - self.deny_by_default = self.deny_by_default.or(other.deny_by_default); - self.disable_auto_readonly = self.disable_auto_readonly.or(other.disable_auto_readonly); - self.override_command.extend(other.override_command); - self - } -} - -#[derive(Decode, Default, Clone, Debug, PartialEq, Eq)] -pub struct NativeTools { - #[knuffel(child, default)] - pub shell: ExecuteShellTool, - #[knuffel(child, default)] - pub aws: AwsTool, - #[knuffel(child, default)] - pub read: ReadTool, - #[knuffel(child, default)] - pub write: WriteTool, -} - -impl NativeTools { - pub fn merge(mut self, other: Self) -> Self { - self.shell = self.shell.merge(other.shell); - self.aws = self.aws.merge(other.aws); - self.read = self.read.merge(other.read); - self.write = self.write.merge(other.write); - self - } -} - -impl From<&NativeTools> for KiroAwsTool { - fn from(value: &NativeTools) -> Self { - KiroAwsTool { - allowed_services: HashSet::from_iter(value.aws.allow.list.iter().cloned()), - denied_services: HashSet::from_iter(value.aws.deny.list.iter().cloned()), - auto_allow_readonly: match value.aws.disable_auto_readonly { - None => true, - Some(f) => !f, - }, - } - } -} - -impl From<&NativeTools> for KiroWriteTool { - fn from(value: &NativeTools) -> Self { - let mut allow: HashSet = HashSet::from_iter(value.write.allow.list.iter().cloned()); - let mut deny: HashSet = HashSet::from_iter(value.write.deny.list.iter().cloned()); - if !value.write.override_path.is_empty() { - tracing::trace!( - "Override/Forcing write: {:?}", - value.shell.override_command.iter().collect::>() - ); - for cmd in value.write.override_path.iter() { - allow.insert(cmd.path.clone()); - if deny.remove(&cmd.path) { - tracing::trace!("Removed from deny: {cmd}"); - } - } - } - KiroWriteTool { - allowed_paths: allow, - denied_paths: deny, - } - } -} - -impl From<&NativeTools> for KiroReadTool { - fn from(value: &NativeTools) -> Self { - let mut allow: HashSet = HashSet::from_iter(value.read.allow.list.iter().cloned()); - let mut deny: HashSet = HashSet::from_iter(value.read.deny.list.iter().cloned()); - if !value.read.override_path.is_empty() { - tracing::trace!( - "Override/Forcing read: {:?}", - value.shell.override_command.iter().collect::>() - ); - for cmd in value.read.override_path.iter() { - allow.insert(cmd.path.clone()); - if deny.remove(&cmd.path) { - tracing::trace!("Removed from deny: {cmd}"); - } - } - } - KiroReadTool { - allowed_paths: allow, - denied_paths: deny, - } - } -} - -impl From<&NativeTools> for KiroShellTool { - fn from(value: &NativeTools) -> Self { - let mut allow: HashSet = HashSet::from_iter(value.shell.allow.list.iter().cloned()); - let mut deny: HashSet = HashSet::from_iter(value.shell.deny.list.iter().cloned()); - if !value.shell.override_command.is_empty() { - tracing::trace!( - "Override/Forcing commands: {:?}", - value.shell.override_command.iter().collect::>() - ); - for cmd in value.shell.override_command.iter() { - allow.insert(cmd.path.clone()); - if deny.remove(&cmd.path) { - tracing::trace!("Removed command from deny: {cmd}"); - } - } - } - - KiroShellTool { - allowed_commands: allow, - denied_commands: deny, - deny_by_default: value.shell.deny_by_default.unwrap_or(false), - auto_allow_readonly: !(value.shell.disable_auto_readonly.unwrap_or(false)), - } - } -} - -#[cfg(test)] -mod tests { - use {super::*, crate::Result}; - - #[test_log::test] - pub fn test_native_merge_empty() -> Result<()> { - let child = NativeTools::default(); - let parent = NativeTools::default(); - let merged = child.merge(parent); - - assert_eq!(merged, NativeTools::default()); - - Ok(()) - } - - #[test_log::test] - pub fn test_native_merge_empty_child() -> Result<()> { - let child = NativeTools::default(); - let mut parent = NativeTools::default(); - let aws = AwsTool { - disable_auto_readonly: None, - allow: vec!["ec2"].into_iter().collect(), - deny: vec!["iam"].into_iter().collect(), - }; - - let shell = ExecuteShellTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_command: HashSet::from_iter(vec![Override::from("rm -rf /")]), - deny_by_default: Some(true), - disable_auto_readonly: Some(false), - }; - - let read = ReadTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_path: HashSet::from_iter(vec![Override::from("rm -rf /")]), - }; - let write = WriteTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_path: HashSet::from_iter(vec![Override::from("rm -rf /")]), - }; - parent.aws = aws.clone(); - parent.shell = shell.clone(); - parent.read = read.clone(); - parent.write = write.clone(); - let merged = child.merge(parent); - assert_eq!(merged.aws, aws); - assert_eq!(merged.shell, shell); - assert_eq!(merged.read, read); - assert_eq!(merged.write, write); - Ok(()) - } - - #[test_log::test] - pub fn test_native_merge_child_parent() -> Result<()> { - let mut child = NativeTools::default(); - let parent = NativeTools { - aws: AwsTool { - disable_auto_readonly: None, - allow: vec!["ec2"].into_iter().collect(), - deny: vec!["iam"].into_iter().collect(), - }, - shell: ExecuteShellTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_command: HashSet::from_iter(vec![Override::from("rm -rf /")]), - deny_by_default: Some(true), - disable_auto_readonly: Some(false), - }, - - read: ReadTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_path: HashSet::from_iter(vec![Override::from("rm -rf /")]), - }, - write: WriteTool { - allow: vec!["ls .*"].into_iter().collect(), - deny: vec!["git push"].into_iter().collect(), - override_path: HashSet::from_iter(vec![Override::from("rm -rf /")]), - }, - }; - - child.aws = AwsTool { - disable_auto_readonly: Some(true), - allow: vec!["ec2"].into_iter().collect(), - ..Default::default() - }; - let merged = child.merge(parent); - assert_eq!(merged.aws.allow, vec!["ec2"].into_iter().collect()); - assert_eq!(merged.aws.deny, vec!["iam"].into_iter().collect()); - assert!(merged.aws.disable_auto_readonly.unwrap_or_default()); - Ok(()) - } - - #[test_log::test] - pub fn test_native_merge_shell() -> Result<()> { - let child = ExecuteShellTool::default(); - let parent = ExecuteShellTool { - deny_by_default: Some(false), - disable_auto_readonly: Some(false), - ..Default::default() - }; - - let merged = child.clone().merge(parent); - assert!(!merged.deny_by_default.unwrap_or_default()); - assert!(!merged.disable_auto_readonly.unwrap_or_default()); - - let parent = ExecuteShellTool { - deny_by_default: Some(true), - disable_auto_readonly: Some(true), - ..Default::default() - }; - let merged = child.clone().merge(parent); - assert!(merged.deny_by_default.unwrap_or_default()); - assert!(merged.disable_auto_readonly.unwrap_or_default()); - - let child = ExecuteShellTool { - deny_by_default: Some(false), - disable_auto_readonly: Some(false), - ..Default::default() - }; - let parent = ExecuteShellTool { - deny_by_default: Some(true), - disable_auto_readonly: Some(true), - ..Default::default() - }; - let merged = child.merge(parent); - assert!(!merged.deny_by_default.unwrap_or_default()); - assert!(!merged.disable_auto_readonly.unwrap_or_default()); - Ok(()) - } - - #[test_log::test] - pub fn test_native_aws_kiro() -> Result<()> { - let a = NativeTools::default(); - let kiro = KiroAwsTool::from(&a); - assert!(kiro.auto_allow_readonly); - assert!(kiro.allowed_services.is_empty()); - assert!(kiro.denied_services.is_empty()); - - let a = NativeTools { - aws: AwsTool { - disable_auto_readonly: Some(true), - allow: "blah".into(), - deny: "blahblah".into(), - }, - ..Default::default() - }; - - let kiro = KiroAwsTool::from(&a); - assert!(!kiro.auto_allow_readonly); - assert!(kiro.allowed_services.contains("blah")); - assert!(kiro.denied_services.contains("blahblah")); - assert_eq!(kiro.allowed_services.len() + kiro.denied_services.len(), 2); - Ok(()) - } - - #[test_log::test] - pub fn test_native_shell_kiro() -> Result<()> { - let a = NativeTools::default(); - let kiro = KiroShellTool::from(&a); - assert!(kiro.auto_allow_readonly); - assert!(kiro.allowed_commands.is_empty()); - assert!(kiro.denied_commands.is_empty()); - - let a = NativeTools { - shell: ExecuteShellTool { - allow: "ls".into(), - deny: "rm".into(), - deny_by_default: None, - disable_auto_readonly: None, - override_command: HashSet::from_iter(vec!["rm".into()]), - }, - ..Default::default() - }; - let kiro = KiroShellTool::from(&a); - assert!(kiro.auto_allow_readonly); - assert_eq!(kiro.allowed_commands.len(), 2); - assert_eq!( - kiro.allowed_commands, - HashSet::from_iter(vec!["ls".to_string(), "rm".to_string()]) - ); - assert!(kiro.denied_commands.is_empty()); - Ok(()) - } - - #[test_log::test] - pub fn test_native_read_kiro() -> Result<()> { - let a = NativeTools::default(); - let kiro = KiroReadTool::from(&a); - assert!(kiro.allowed_paths.is_empty()); - assert!(kiro.denied_paths.is_empty()); - - let a = NativeTools { - read: ReadTool { - allow: "ls".into(), - deny: "rm".into(), - override_path: HashSet::from_iter(vec!["rm".into()]), - }, - ..Default::default() - }; - let kiro = KiroReadTool::from(&a); - assert_eq!(kiro.allowed_paths.len(), 2); - assert_eq!( - kiro.allowed_paths, - HashSet::from_iter(vec!["ls".to_string(), "rm".to_string()]) - ); - assert!(kiro.denied_paths.is_empty()); - Ok(()) - } - - #[test_log::test] - pub fn test_native_write_kiro() -> Result<()> { - let a = NativeTools::default(); - let kiro = KiroWriteTool::from(&a); - assert!(kiro.allowed_paths.is_empty()); - assert!(kiro.denied_paths.is_empty()); - - let a = NativeTools { - write: WriteTool { - allow: "ls".into(), - deny: "rm".into(), - override_path: HashSet::from_iter(vec!["rm".into()]), - }, - ..Default::default() - }; - let kiro = KiroWriteTool::from(&a); - assert_eq!(kiro.allowed_paths.len(), 2); - assert_eq!( - kiro.allowed_paths, - HashSet::from_iter(vec!["ls".to_string(), "rm".to_string()]) - ); - assert!(kiro.denied_paths.is_empty()); - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs index e32b782..63e9c7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ mod agent; mod commands; +mod config; +mod error; mod generator; -mod kdl; +// mod kdl; mod os; pub mod output; mod schema; @@ -10,13 +12,15 @@ mod source; use { crate::{generator::Generator, os::Fs}, clap::Parser, - color_eyre::eyre::Context, + miette::{Context, IntoDiagnostic}, std::path::Path, tracing::{debug, enabled}, tracing_error::ErrorLayer, tracing_subscriber::prelude::*, }; -pub type Result = color_eyre::Result; +pub use {error::Error, miette::miette as format_err}; +pub type Result = miette::Result; + pub(crate) const DOCS_URL: &str = "https://kg.cartera-mesh.com"; fn init_tracing(debug: bool, trace_agent: Option<&str>) { @@ -78,7 +82,7 @@ async fn init(fs: &Fs, gen_dir: impl AsRef) -> Result<()> { let gen_dir = gen_dir.as_ref(); let kg_config = gen_dir.join("kg.kdl"); if fs.exists(&kg_config) { - return Err(color_eyre::eyre::format_err!( + return Err(format_err!( "kg.kdl already exists at {}", kg_config.display() )); @@ -87,6 +91,7 @@ async fn init(fs: &Fs, gen_dir: impl AsRef) -> Result<()> { if !fs.exists(gen_dir) { fs.create_dir_all(gen_dir) .await + .into_diagnostic() .wrap_err_with(|| format!("failed to create directory {}", gen_dir.display()))?; } @@ -101,6 +106,7 @@ async fn init(fs: &Fs, gen_dir: impl AsRef) -> Result<()> { let dest = gen_dir.join(filename); fs.write(&dest, content) .await + .into_diagnostic() .wrap_err_with(|| format!("failed to write {}", dest.display()))?; println!("Created {}", dest.display()); } @@ -116,7 +122,6 @@ async fn init(fs: &Fs, gen_dir: impl AsRef) -> Result<()> { #[tokio::main] async fn main() -> Result<()> { - color_eyre::install()?; let cli = commands::Cli::parse(); if matches!(cli.command, commands::Command::Version) { println!("{}", clap::crate_version!()); @@ -148,7 +153,9 @@ async fn main() -> Result<()> { "changing working directory to {}", home_dir.as_os_str().display() ); - std::env::set_current_dir(&home_dir)?; + std::env::set_current_dir(&home_dir) + .into_diagnostic() + .wrap_err(format!("failed to set CWD {}", home_dir.display()))?; } if local_mode { span.record("local_mode", true); @@ -172,7 +179,9 @@ async fn main() -> Result<()> { if enabled!(tracing::Level::TRACE) { tracing::trace!( "Loaded Agent Generator Config:\n{}", - serde_json::to_string_pretty(&q_generator_config)? + serde_json::to_string_pretty(&q_generator_config) + .into_diagnostic() + .wrap_err("unable to decode to json")? ); } diff --git a/src/output.rs b/src/output.rs index 2b8f8a7..e2f5b1c 100644 --- a/src/output.rs +++ b/src/output.rs @@ -6,11 +6,23 @@ use { source::KdlSources, }, colored::Colorize, + miette::{Context, GraphicalReportHandler, GraphicalTheme, IntoDiagnostic}, std::fmt::Display, super_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, *}, tracing::enabled, }; +pub fn print_error(e: &crate::Error) { + match e { + crate::Error::DeserializeError(file, kdl_err) => { + let mut output = String::new(); + let handler = GraphicalReportHandler::new_themed(GraphicalTheme::unicode()); + handler.render_report(&mut output, kdl_err).unwrap(); + eprintln!("{}\nFile location: '{}'", output, file); + } + }; +} + /// Override the color setting. Default is [`ColorOverride::Auto`]. #[derive(Copy, Clone, Debug, Default, clap::ValueEnum)] pub enum ColorOverride { @@ -129,7 +141,7 @@ impl OutputFormat { // MCP servers (only enabled ones) let mut servers = Vec::new(); - for (k, v) in &result.agent.mcp_servers() { + for (k, v) in &result.agent.mcp { if !v.disabled { servers.push(k.clone()); } @@ -140,14 +152,14 @@ impl OutputFormat { // Allowed tools let mut allowed_tools: Vec = result .agent - .allowed_tools() + .allowed_tools .iter() .filter(|t| !t.is_empty()) .cloned() .collect(); allowed_tools.sort(); let mut enabled_tools = Vec::with_capacity(allowed_tools.len()); - let mcps = result.agent.mcp_servers(); + let mcps = &result.agent.mcp; for t in allowed_tools { if t.len() < 2 { continue; @@ -179,7 +191,7 @@ impl OutputFormat { } // resources - if let Some(resources) = serialize_yaml("", &result.resources()) { + if let Some(resources) = serialize_yaml("", &Vec::from_iter(result.resources())) { row.add_cell(resources); } @@ -289,7 +301,12 @@ impl OutputFormat { } Self::Json => { let kiro_agents: Vec = results.into_iter().map(|a| a.kiro_agent).collect(); - println!("{}", serde_json::to_string_pretty(&kiro_agents)?); + println!( + "{}", + serde_json::to_string_pretty(&kiro_agents) + .into_diagnostic() + .wrap_err("todo")? + ); Ok(()) } }