Compare commits

..

24 Commits

Author SHA1 Message Date
Gregory Schier
c415e7f471 Add MCP client plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:15:25 -08:00
Gregory Schier
8023603ebe Add CLI command architecture plan 2026-02-08 08:02:34 -08:00
Gregory Schier
66942eaf2c Update httpsnippet plugin README and bump to v1.0.3 2026-02-07 15:20:20 -08:00
Zhizhen He
38796b1833 feat: add delete folder and copy id actions to folder settings (#380) 2026-02-07 08:48:38 -08:00
dependabot[bot]
49ffa6fc45 Bump bytes from 1.10.1 to 1.11.1 (#379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 08:48:03 -08:00
Brijmohan Siyag
1f56ba2eb6 Fix text input autocomplete not working with completionOptions (#368) 2026-02-07 08:47:50 -08:00
Gregory Schier
f98a70ecb4 Add dynamic() support to prompt.form() plugin API (#386) 2026-02-07 08:09:40 -08:00
dependabot[bot]
2984eb40c9 Bump time from 0.3.41 to 0.3.47 (#385)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 16:50:40 -08:00
Gregory Schier
cc5d4742f0 Don't select current request by default for response chaining 2026-02-05 13:08:08 -08:00
Gregory Schier
5b8e4b98a0 Use "send" preview mode for copy-as-curl 2026-02-05 08:31:28 -08:00
Gregory Schier
8637c90a21 Fix CSV responses ignoring raw view mode 2026-02-05 07:57:12 -08:00
dependabot[bot]
b88c5e71a0 Bump @modelcontextprotocol/sdk from 1.25.2 to 1.26.0 (#383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:54:01 -08:00
dependabot[bot]
1899d512ab Bump git2 from 0.20.2 to 0.20.4 (#384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 16:53:50 -08:00
Gregory Schier
7c31718f5e Git Improvements (#382) 2026-02-04 11:46:04 -08:00
Gregory Schier
8f1463e5d0 More cleanup 2026-02-04 06:45:55 -08:00
Gregory Schier
0dc8807808 Cleanup more unused functions 2026-02-04 06:44:13 -08:00
Gregory Schier
f24a159b8a Clean up unused functions 2026-02-04 06:28:45 -08:00
Zhizhen He
0b91d3aaff feat: add breadcrumbs to folder setting (#296) 2026-02-03 07:40:24 -08:00
Gregory Schier
431dc1c896 Adjust dev menu 2026-02-02 17:58:58 -08:00
Gregory Schier
bc8277b56b Fix header behavior on cross-origin redirects (#378)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:58:27 -08:00
gschier
0afed185d9 Deploying to main from @ mountain-loop/yaak@55cee00601 🚀 2026-02-02 15:46:27 +00:00
Gregory Schier
55cee00601 More reliable plugin runtime kill 2026-02-02 07:45:19 -08:00
Gregory Schier
b41a8e04cb Graceful oauth server shutdown 2026-02-02 07:31:55 -08:00
Gregory Schier
eff4519d91 Have cancellation work before the request is sent 2026-02-02 07:09:48 -08:00
95 changed files with 2272 additions and 2927 deletions

65
Cargo.lock generated
View File

@@ -689,9 +689,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
@@ -1316,12 +1316,12 @@ checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "deranged"
version = "0.4.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
"serde",
"serde_core",
]
[[package]]
@@ -2136,9 +2136,9 @@ dependencies = [
[[package]]
name = "git2"
version = "0.20.2"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
dependencies = [
"bitflags 2.9.1",
"libc",
@@ -3036,9 +3036,9 @@ dependencies = [
[[package]]
name = "libgit2-sys"
version = "0.18.1+1.9.0"
version = "0.18.3+1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e"
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
dependencies = [
"cc",
"libc",
@@ -3446,9 +3446,9 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-traits"
@@ -6341,9 +6341,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.41"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -6351,22 +6351,22 @@ dependencies = [
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.22"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -7994,33 +7994,6 @@ dependencies = [
"rustix 1.0.7",
]
[[package]]
name = "yaak-actions"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"ts-rs",
]
[[package]]
name = "yaak-actions-builtin"
version = "0.1.0"
dependencies = [
"log",
"serde",
"serde_json",
"tokio",
"yaak-actions",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-templates",
]
[[package]]
name = "yaak-app"
version = "0.0.0"
@@ -8090,8 +8063,6 @@ dependencies = [
"log",
"serde_json",
"tokio",
"yaak-actions",
"yaak-actions-builtin",
"yaak-crypto",
"yaak-http",
"yaak-models",

View File

@@ -2,8 +2,6 @@
resolver = "2"
members = [
# Shared crates (no Tauri dependency)
"crates/yaak-actions",
"crates/yaak-actions-builtin",
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
@@ -47,8 +45,6 @@ tokio = "1.48.0"
ts-rs = "11.1.0"
# Internal crates - shared
yaak-actions = { path = "crates/yaak-actions" }
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
yaak-core = { path = "crates/yaak-core" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }

27
MCP_CLIENT_PLAN.md Normal file
View File

@@ -0,0 +1,27 @@
# MCP Client Plan
## Goal
Add an MCP client mode to Yaak so users can connect to and debug MCP servers.
## Core Design
- **Protocol layer:** Implement JSONRPC framing, message IDs, and notifications as the common core.
- **Transport interface:** Define an async trait with `connect`, `send`, `receive`, and `close` methods.
- **Transports:**
- Start with **Standard I/O** for local development.
- Reuse the existing HTTP stack for **HTTP streaming** next.
- Leave hooks for **WebSocket** support later.
## Integration
- Register MCP as a new request type alongside REST, GraphQL, gRPC, and WebSocket.
- Allow perrequest transport selection (stdio or HTTP).
- Map inbound messages into a new MCP response model that feeds existing timeline and debug views.
## Testing and Dogfooding
- Convert Yaak's own MCP server to Standard I/O for local testing.
- Use it internally to validate protocol behavior and message flow.
- Add unit and integration tests for JSONRPC messaging and transport abstractions.
## Future Refinements
- Add WebSocket transport support once core paths are stable.
- Extend timelines for protocollevel visualization layered over raw transport events.
- Implement version and capability negotiation between client and server.

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)

View File

@@ -15,8 +15,6 @@ env_logger = "0.11"
log = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
yaak-actions = { workspace = true }
yaak-actions-builtin = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }

198
crates-cli/yaak-cli/PLAN.md Normal file
View File

@@ -0,0 +1,198 @@
# CLI Command Architecture Plan
## Goal
Redesign the yaak-cli command structure to use a resource-oriented `<resource> <action>`
pattern that scales well, is discoverable, and supports both human and LLM workflows.
## Command Architecture
### Design Principles
- **Resource-oriented**: top-level commands are nouns, subcommands are verbs
- **Polymorphic requests**: `request` covers HTTP, gRPC, and WebSocket — the CLI
resolves the type via `get_any_request` and adapts behavior accordingly
- **Simple creation, full-fidelity via JSON**: human-friendly flags for basic creation,
`--json` for full control (targeted at LLM and scripting workflows)
- **Runtime schema introspection**: `request schema` outputs JSON Schema for the request
models, with dynamic auth fields populated from loaded plugins at runtime
- **Destructive actions require confirmation**: `delete` commands prompt for user
confirmation before proceeding. Can be bypassed with `--yes` / `-y` for scripting
### Commands
```
# Top-level shortcut
yaakcli send <id> [-e <env_id>] # id can be a request, folder, or workspace
# Resource commands
yaakcli workspace list
yaakcli workspace show <id>
yaakcli workspace create --name <name>
yaakcli workspace create --json '{"name": "My Workspace"}'
yaakcli workspace create '{"name": "My Workspace"}' # positional JSON shorthand
yaakcli workspace update --json '{"id": "wk_abc", "name": "New Name"}'
yaakcli workspace delete <id>
yaakcli request list <workspace_id>
yaakcli request show <id>
yaakcli request create <workspace_id> --name <name> --url <url> [--method GET]
yaakcli request create --json '{"workspaceId": "wk_abc", "url": "..."}'
yaakcli request update --json '{"id": "rq_abc", "url": "https://new.com"}'
yaakcli request send <id> [-e <env_id>]
yaakcli request delete <id>
yaakcli request schema <http|grpc|websocket>
yaakcli folder list <workspace_id>
yaakcli folder show <id>
yaakcli folder create <workspace_id> --name <name>
yaakcli folder create --json '{"workspaceId": "wk_abc", "name": "Auth"}'
yaakcli folder update --json '{"id": "fl_abc", "name": "New Name"}'
yaakcli folder delete <id>
yaakcli environment list <workspace_id>
yaakcli environment show <id>
yaakcli environment create <workspace_id> --name <name>
yaakcli environment create --json '{"workspaceId": "wk_abc", "name": "Production"}'
yaakcli environment update --json '{"id": "ev_abc", ...}'
yaakcli environment delete <id>
```
### `send` — Top-Level Shortcut
`yaakcli send <id>` is a convenience alias that accepts any sendable ID. It tries
each type in order via DB lookups (short-circuiting on first match):
1. Request (HTTP, gRPC, or WebSocket via `get_any_request`)
2. Folder (sends all requests in the folder)
3. Workspace (sends all requests in the workspace)
ID prefixes exist (e.g. `rq_`, `fl_`, `wk_`) but are not relied upon — resolution
is purely by DB lookup.
`request send <id>` is the same but restricted to request IDs only.
### Request Send — Polymorphic Behavior
`send` means "execute this request" regardless of protocol:
- **HTTP**: send request, print response, exit
- **gRPC**: invoke the method; for streaming, stream output to stdout until done/Ctrl+C
- **WebSocket**: connect, stream messages to stdout until closed/Ctrl+C
### `request schema` — Runtime JSON Schema
Outputs a JSON Schema describing the full request shape, including dynamic fields:
1. Generate base schema from `schemars::JsonSchema` derive on the Rust model structs
2. Load plugins, collect auth strategy definitions and their form inputs
3. Merge plugin-defined auth fields into the `authentication` property as a `oneOf`
4. Output the combined schema as JSON
This lets an LLM call `schema`, read the shape, and construct valid JSON for
`create --json` or `update --json`.
## Implementation Steps
### Phase 1: Restructure commands (no new functionality)
Refactor `main.rs` into the new resource/action pattern using clap subcommand nesting.
Existing behavior stays the same, just reorganized. Remove the `get` command.
1. Create module structure: `commands/workspace.rs`, `commands/request.rs`, etc.
2. Define nested clap enums:
```rust
enum Commands {
Send(SendArgs),
Workspace(WorkspaceArgs),
Request(RequestArgs),
Folder(FolderArgs),
Environment(EnvironmentArgs),
}
```
3. Move existing `Workspaces` logic into `workspace list`
4. Move existing `Requests` logic into `request list`
5. Move existing `Send` logic into `request send`
6. Move existing `Create` logic into `request create`
7. Delete the `Get` command entirely
8. Extract shared setup (DB init, plugin init, encryption) into a reusable context struct
### Phase 2: Add missing CRUD commands
1. `workspace show <id>`
2. `workspace create --name <name>` (and `--json`)
3. `workspace update --json`
4. `workspace delete <id>`
5. `request show <id>` (JSON output of the full request model)
6. `request delete <id>`
7. `folder list <workspace_id>`
8. `folder show <id>`
9. `folder create <workspace_id> --name <name>` (and `--json`)
10. `folder update --json`
11. `folder delete <id>`
12. `environment list <workspace_id>`
13. `environment show <id>`
14. `environment create <workspace_id> --name <name>` (and `--json`)
15. `environment update --json`
16. `environment delete <id>`
### Phase 3: JSON input for create/update
Both commands accept JSON via `--json <string>` or as a positional argument (detected
by leading `{`). They follow the same upsert pattern as the plugin API.
- **`create --json`**: JSON must include `workspaceId`. Must NOT include `id` (or
use empty string `""`). Deserializes into the model with defaults for missing fields,
then upserts (insert).
- **`update --json`**: JSON must include `id`. Performs a fetch-merge-upsert:
1. Fetch the existing model from DB
2. Serialize it to `serde_json::Value`
3. Deep-merge the user's partial JSON on top (JSON Merge Patch / RFC 7386 semantics)
4. Deserialize back into the typed model
5. Upsert (update)
This matches how the MCP server plugin already does it (fetch existing, spread, override),
but the CLI handles the merge server-side so callers don't have to.
Setting a field to `null` removes it (for `Option<T>` fields), per RFC 7386.
Implementation:
1. Add `--json` flag and positional JSON detection to `create` commands
2. Add `update` commands with required `--json` flag
3. Implement JSON merge utility (or use `json-patch` crate)
### Phase 4: Runtime schema generation
1. Add `schemars` dependency to `yaak-models`
2. Derive `JsonSchema` on `HttpRequest`, `GrpcRequest`, `WebsocketRequest`, and their
nested types (`HttpRequestHeader`, `HttpUrlParameter`, etc.)
3. Implement `request schema` command:
- Generate base schema from schemars
- Query plugins for auth strategy form inputs
- Convert plugin form inputs into JSON Schema properties
- Merge into the `authentication` field
- Print to stdout
### Phase 5: Polymorphic send
1. Update `request send` to use `get_any_request` to resolve the request type
2. Match on `AnyRequest` variant and dispatch to the appropriate sender:
- `AnyRequest::HttpRequest` — existing HTTP send logic
- `AnyRequest::GrpcRequest` — gRPC invoke (future implementation)
- `AnyRequest::WebsocketRequest` — WebSocket connect (future implementation)
3. gRPC and WebSocket send can initially return "not yet implemented" errors
### Phase 6: Top-level `send` and folder/workspace send
1. Add top-level `yaakcli send <id>` command
2. Resolve ID by trying DB lookups in order: any_request → folder → workspace
3. For folder: list all requests in folder, send each
4. For workspace: list all requests in workspace, send each
5. Add execution options: `--sequential` (default), `--parallel`, `--fail-fast`
## Crate Changes
- **yaak-cli**: restructure into modules, new clap hierarchy
- **yaak-models**: add `schemars` dependency, derive `JsonSchema` on model structs
(current derives: `Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS`)

View File

@@ -1,13 +1,21 @@
use clap::{Parser, Subcommand};
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::HttpRequest;
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
#[derive(Parser)]
#[command(name = "yaakcli")]
@@ -64,6 +72,86 @@ enum Commands {
},
}
/// Render an HTTP request with template variables and plugin functions
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt).await?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
@@ -88,6 +176,10 @@ async fn main() {
let db = query_manager.connect();
// Initialize encryption manager for secure() template function
// Use the same app_id as the Tauri app for keyring access
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
// Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-plugins");
@@ -106,9 +198,9 @@ async fn main() {
// Create plugin manager (plugins may not be available in CLI context)
let plugin_manager = Arc::new(
PluginManager::new(
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
false,
)
@@ -147,67 +239,94 @@ async fn main() {
}
}
Commands::Send { request_id } => {
use yaak_actions::{
ActionExecutor, ActionId, ActionParams, ActionResult, ActionTarget, CurrentContext,
};
use yaak_actions_builtin::{BuiltinActionDependencies, register_http_actions};
let request = db.get_http_request(&request_id).expect("Failed to get request");
// Create dependencies
let deps = BuiltinActionDependencies::new_standalone(
&db_path,
&blob_path,
&app_id,
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
// Resolve environment chain for variable substitution
let environment_chain = db
.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
cli.environment.as_deref(),
)
.unwrap_or_default();
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
.expect("Failed to initialize dependencies");
.expect("Failed to render request templates");
// Create executor and register actions
let executor = ActionExecutor::new();
executor.register_builtin_groups().await.expect("Failed to register groups");
register_http_actions(&executor, &deps).await.expect("Failed to register HTTP actions");
// Prepare context
let context = CurrentContext {
target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
environment_id: cli.environment.clone(),
workspace_id: None,
has_window: false,
can_prompt: false,
};
// Prepare params
let params = ActionParams {
data: serde_json::json!({
"render": true,
"follow_redirects": false,
"timeout_ms": 30000,
}),
};
// Invoke action
let action_id = ActionId::builtin("http", "send-request");
let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
// Handle result
match result {
ActionResult::Success { data, message } => {
if let Some(msg) = message {
println!("{}", msg);
}
if let Some(data) = data {
println!("{}", serde_json::to_string_pretty(&data).unwrap());
}
}
ActionResult::RequiresInput { .. } => {
eprintln!("Action requires input (not supported in CLI)");
}
ActionResult::Cancelled => {
eprintln!("Action cancelled");
}
if cli.verbose {
println!("> {} {}", rendered_request.method, rendered_request.url);
}
// Convert to sendable request
let sendable = SendableHttpRequest::from_http_request(
&rendered_request,
SendableHttpRequestOptions::default(),
)
.await
.expect("Failed to build request");
// Create event channel for progress
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to print events if verbose
let verbose = cli.verbose;
let verbose_handle = if verbose {
Some(tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
println!("{}", event);
}
}))
} else {
// Drain events silently
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
None
};
// Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
// Wait for event handler to finish
if let Some(handle) = verbose_handle {
let _ = handle.await;
}
// Print response
if verbose {
println!();
}
println!(
"HTTP {} {}",
response.status,
response.status_reason.as_deref().unwrap_or("")
);
if verbose {
for (name, value) in &response.headers {
println!("{}: {}", name, value);
}
println!();
}
// Print body
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
}
Commands::Get { url } => {
if cli.verbose {

View File

@@ -2,7 +2,6 @@ use crate::PluginContextExt;
use crate::error::Result;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::HttpRequestHeader;
use yaak_models::queries::workspaces::default_headers;
@@ -23,20 +22,6 @@ impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
}
}
#[command]
pub(crate) async fn cmd_show_workspace_key<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
let key = window.crypto().reveal_workspace_key(workspace_id)?;
window
.dialog()
.message(format!("Your workspace key is \n\n{}", key))
.kind(MessageDialogKind::Info)
.show(|_v| {});
Ok(())
}
#[command]
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
window: WebviewWindow<R>,

View File

@@ -9,8 +9,8 @@ use yaak_git::{
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
git_rm_remote, git_status, git_unstage,
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
};
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
@@ -89,6 +89,20 @@ pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
Ok(git_pull(dir).await?)
}
#[command]
pub async fn cmd_git_pull_force_reset(
dir: &Path,
remote: &str,
branch: &str,
) -> Result<PullResult> {
Ok(git_pull_force_reset(dir, remote, branch).await?)
}
#[command]
pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
Ok(git_pull_merge(dir, remote, branch).await?)
}
#[command]
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
for path in rela_paths {
@@ -105,6 +119,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
Ok(())
}
#[command]
pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
Ok(git_reset_changes(dir).await?)
}
#[command]
pub async fn cmd_git_add_credential(
remote_url: &str,

View File

@@ -182,7 +182,14 @@ async fn send_http_request_inner<R: Runtime>(
);
let env_chain =
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
let mut cancel_rx = cancelled_rx.clone();
let render_options = RenderOptions::throw();
let request = tokio::select! {
result = render_http_request(&resolved, env_chain, &cb, &render_options) => result?,
_ = cancel_rx.changed() => {
return Err(GenericError("Request canceled".to_string()));
}
};
// Build the sendable request using the new SendableHttpRequest type
let options = SendableHttpRequestOptions {
@@ -244,16 +251,22 @@ async fn send_http_request_inner<R: Runtime>(
})
.await?;
// Apply authentication to the request
apply_authentication(
&window,
&mut sendable_request,
&request,
auth_context_id,
&plugin_manager,
plugin_context,
)
.await?;
// Apply authentication to the request, racing against cancellation since
// auth plugins (e.g. OAuth2) can block indefinitely waiting for user action.
let mut cancel_rx = cancelled_rx.clone();
tokio::select! {
result = apply_authentication(
&window,
&mut sendable_request,
&request,
auth_context_id,
&plugin_manager,
plugin_context,
) => result?,
_ = cancel_rx.changed() => {
return Err(GenericError("Request canceled".to_string()));
}
};
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction(

View File

@@ -37,8 +37,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
Plugin, Workspace, WorkspaceMeta,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
Workspace, WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{
@@ -1271,35 +1271,6 @@ async fn cmd_save_response<R: Runtime>(
Ok(())
}
#[tauri::command]
async fn cmd_send_folder<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
folder_id: &str,
) -> YaakResult<()> {
let requests = app_handle.db().list_http_requests_for_folder_recursive(folder_id)?;
for request in requests {
let app_handle = app_handle.clone();
let window = window.clone();
let environment_id = environment_id.clone();
let cookie_jar_id = cookie_jar_id.clone();
tokio::spawn(async move {
let _ = cmd_send_http_request(
app_handle,
window,
environment_id.as_deref(),
cookie_jar_id.as_deref(),
request,
)
.await;
});
}
Ok(())
}
#[tauri::command]
async fn cmd_send_http_request<R: Runtime>(
app_handle: AppHandle<R>,
@@ -1396,27 +1367,6 @@ async fn cmd_install_plugin<R: Runtime>(
Ok(plugin)
}
#[tauri::command]
async fn cmd_create_grpc_request<R: Runtime>(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<GrpcRequest> {
Ok(app_handle.db().upsert_grpc_request(
&GrpcRequest {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
folder_id: folder_id.map(|s| s.to_string()),
sort_priority,
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?)
}
#[tauri::command]
async fn cmd_reload_plugins<R: Runtime>(
app_handle: AppHandle<R>,
@@ -1679,7 +1629,6 @@ pub fn run() {
cmd_call_folder_action,
cmd_call_grpc_request_action,
cmd_check_for_updates,
cmd_create_grpc_request,
cmd_curl_to_request,
cmd_delete_all_grpc_connections,
cmd_delete_all_http_responses,
@@ -1713,7 +1662,6 @@ pub fn run() {
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
cmd_send_folder,
cmd_template_function_config,
cmd_template_function_summaries,
cmd_template_tokens_to_string,
@@ -1728,7 +1676,6 @@ pub fn run() {
crate::commands::cmd_reveal_workspace_key,
crate::commands::cmd_secure_template,
crate::commands::cmd_set_workspace_key,
crate::commands::cmd_show_workspace_key,
//
// Models commands
models_ext::models_delete,
@@ -1762,8 +1709,11 @@ pub fn run() {
git_ext::cmd_git_fetch_all,
git_ext::cmd_git_push,
git_ext::cmd_git_pull,
git_ext::cmd_git_pull_force_reset,
git_ext::cmd_git_pull_merge,
git_ext::cmd_git_add,
git_ext::cmd_git_unstage,
git_ext::cmd_git_reset_changes,
git_ext::cmd_git_add_credential,
git_ext::cmd_git_remotes,
git_ext::cmd_git_add_remote,
@@ -1777,14 +1727,7 @@ pub fn run() {
plugins_ext::cmd_plugins_update_all,
//
// WebSocket commands
ws_ext::cmd_ws_upsert_request,
ws_ext::cmd_ws_duplicate_request,
ws_ext::cmd_ws_delete_request,
ws_ext::cmd_ws_delete_connection,
ws_ext::cmd_ws_delete_connections,
ws_ext::cmd_ws_list_events,
ws_ext::cmd_ws_list_requests,
ws_ext::cmd_ws_list_connections,
ws_ext::cmd_ws_send,
ws_ext::cmd_ws_close,
ws_ext::cmd_ws_connect,

View File

@@ -12,7 +12,7 @@ use chrono::Utc;
use cookie::Cookie;
use log::error;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
use yaak_crypto::manager::EncryptionManager;
@@ -59,7 +59,55 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
if event.reply_id.is_some() {
// Follow-up update from plugin runtime with resolved inputs — forward to frontend
window.emit_to(window.label(), "plugin_event", event.clone())?;
Ok(None)
} else {
// Initial request — set up bidirectional communication
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
let event_id = event.id.clone();
let plugin_handle = plugin_handle.clone();
let plugin_context = plugin_context.clone();
let window = window.clone();
// Spawn async task to handle bidirectional form communication
tauri::async_runtime::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);
// Listen for replies from the frontend
let listener_id = window.listen(event_id, move |ev: tauri::Event| {
let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();
let _ = tx.try_send(resp);
});
// Forward each reply to the plugin runtime
while let Some(resp) = rx.recv().await {
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {:?}", e);
}
if is_done {
break;
}
}
window.unlisten(listener_id);
});
Ok(None)
}
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle

View File

@@ -162,11 +162,16 @@ pub(crate) fn create_window<R: Runtime>(
"dev.reset_size" => webview_window
.set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))
.unwrap(),
"dev.reset_size_record" => {
"dev.reset_size_16x9" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
}
"dev.reset_size_16x10" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 10 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
}
"dev.refresh" => webview_window.eval("location.reload()").unwrap(),
"dev.generate_theme_css" => {
w.emit("generate_theme_css", true).unwrap();

View File

@@ -154,8 +154,13 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.reset_size_record".to_string(),
"Reset Size 16x9",
"dev.reset_size_16x9".to_string(),
"Resize to 16x9",
)
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.reset_size_16x10".to_string(),
"Resize to 16x10",
)
.build(app_handle)?,
&MenuItemBuilder::with_id(

View File

@@ -28,52 +28,6 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_ws::{WebsocketManager, render_websocket_request};
#[command]
pub async fn cmd_ws_upsert_request<R: Runtime>(
request: WebsocketRequest,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<WebsocketRequest> {
Ok(app_handle
.db()
.upsert_websocket_request(&request, &UpdateSource::from_window_label(window.label()))?)
}
#[command]
pub async fn cmd_ws_duplicate_request<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<WebsocketRequest> {
let db = app_handle.db();
let request = db.get_websocket_request(request_id)?;
Ok(db.duplicate_websocket_request(&request, &UpdateSource::from_window_label(window.label()))?)
}
#[command]
pub async fn cmd_ws_delete_request<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<WebsocketRequest> {
Ok(app_handle.db().delete_websocket_request_by_id(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
#[command]
pub async fn cmd_ws_delete_connection<R: Runtime>(
connection_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<WebsocketConnection> {
Ok(app_handle.db().delete_websocket_connection_by_id(
connection_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
#[command]
pub async fn cmd_ws_delete_connections<R: Runtime>(
request_id: &str,
@@ -86,30 +40,6 @@ pub async fn cmd_ws_delete_connections<R: Runtime>(
)?)
}
#[command]
pub async fn cmd_ws_list_events<R: Runtime>(
connection_id: &str,
app_handle: AppHandle<R>,
) -> Result<Vec<WebsocketEvent>> {
Ok(app_handle.db().list_websocket_events(connection_id)?)
}
#[command]
pub async fn cmd_ws_list_requests<R: Runtime>(
workspace_id: &str,
app_handle: AppHandle<R>,
) -> Result<Vec<WebsocketRequest>> {
Ok(app_handle.db().list_websocket_requests(workspace_id)?)
}
#[command]
pub async fn cmd_ws_list_connections<R: Runtime>(
workspace_id: &str,
app_handle: AppHandle<R>,
) -> Result<Vec<WebsocketConnection>> {
Ok(app_handle.db().list_websocket_connections(workspace_id)?)
}
#[command]
pub async fn cmd_ws_send<R: Runtime>(
connection_id: &str,

View File

@@ -1,18 +0,0 @@
[package]
name = "yaak-actions-builtin"
version = "0.1.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
yaak-actions = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-templates = { workspace = true }
yaak-plugins = { workspace = true }
yaak-crypto = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt-multi-thread"] }
log = { workspace = true }

View File

@@ -1,88 +0,0 @@
//! Dependency injection for built-in actions.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use yaak_crypto::manager::EncryptionManager;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Dependencies needed by built-in action implementations.
///
/// This struct bundles all the dependencies that action handlers need,
/// providing a clean way to initialize them in different contexts
/// (CLI, Tauri app, MCP server, etc.).
pub struct BuiltinActionDependencies {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
impl BuiltinActionDependencies {
/// Create dependencies for standalone usage (CLI, MCP server, etc.)
///
/// This initializes all the necessary managers following the same pattern
/// as the yaak-cli implementation.
pub async fn new_standalone(
db_path: &Path,
blob_path: &Path,
app_id: &str,
plugin_vendored_dir: PathBuf,
plugin_installed_dir: PathBuf,
node_path: PathBuf,
) -> Result<Self, Box<dyn std::error::Error>> {
// Initialize database
let (query_manager, _, _) = yaak_models::init_standalone(db_path, blob_path)?;
// Initialize encryption manager (takes QueryManager by value)
let encryption_manager = Arc::new(EncryptionManager::new(
query_manager.clone(),
app_id.to_string(),
));
let query_manager = Arc::new(query_manager);
// Find plugin runtime
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
// Development fallback
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
// Initialize plugin manager
let plugin_manager = Arc::new(
PluginManager::new(
plugin_vendored_dir,
plugin_installed_dir,
node_path,
plugin_runtime_main,
false, // not sandboxed in CLI
)
.await,
);
// Initialize plugins from database
let db = query_manager.connect();
let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors = plugin_manager
.initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors {
log::warn!(
"Failed to initialize plugin '{}': {}",
plugin_dir,
error_msg
);
}
}
Ok(Self {
query_manager,
plugin_manager,
encryption_manager,
})
}
}

View File

@@ -1,24 +0,0 @@
//! HTTP action implementations.
pub mod send;
use crate::BuiltinActionDependencies;
use yaak_actions::{ActionError, ActionExecutor, ActionSource};
/// Register all HTTP-related actions with the executor.
pub async fn register_http_actions(
executor: &ActionExecutor,
deps: &BuiltinActionDependencies,
) -> Result<(), ActionError> {
let handler = send::HttpSendActionHandler {
query_manager: deps.query_manager.clone(),
plugin_manager: deps.plugin_manager.clone(),
encryption_manager: deps.encryption_manager.clone(),
};
executor
.register(send::metadata(), ActionSource::Builtin, handler)
.await?;
Ok(())
}

View File

@@ -1,293 +0,0 @@
//! HTTP send action implementation.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use yaak_actions::{
ActionError, ActionGroupId, ActionHandler, ActionId, ActionMetadata,
ActionParams, ActionResult, ActionScope, CurrentContext,
RequiredContext,
};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
/// Handler for HTTP send action.
pub struct HttpSendActionHandler {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
/// Metadata for the HTTP send action.
pub fn metadata() -> ActionMetadata {
ActionMetadata {
id: ActionId::builtin("http", "send-request"),
label: "Send HTTP Request".to_string(),
description: Some("Execute an HTTP request and return the response".to_string()),
icon: Some("play".to_string()),
scope: ActionScope::HttpRequest,
keyboard_shortcut: None,
requires_selection: true,
enabled_condition: None,
group_id: Some(ActionGroupId::builtin("send")),
order: 10,
required_context: RequiredContext::requires_target(),
}
}
impl ActionHandler for HttpSendActionHandler {
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ActionResult, ActionError>> + Send + 'static>,
> {
let query_manager = self.query_manager.clone();
let plugin_manager = self.plugin_manager.clone();
let encryption_manager = self.encryption_manager.clone();
Box::pin(async move {
// Extract request_id from context
let request_id = context
.target
.as_ref()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target".to_string()],
}
})?
.id()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target.id".to_string()],
}
})?
.to_string();
// Fetch request and environment from database (synchronous)
let (request, environment_chain) = {
let db = query_manager.connect();
// Fetch HTTP request from database
let request = db.get_http_request(&request_id).map_err(|e| {
ActionError::Internal(format!("Failed to fetch request {}: {}", request_id, e))
})?;
// Resolve environment chain for variable substitution
let environment_chain = if let Some(env_id) = &context.environment_id {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
Some(env_id),
)
.unwrap_or_default()
} else {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
None,
)
.unwrap_or_default()
};
(request, environment_chain)
}; // db is dropped here
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
.map_err(|e| ActionError::Internal(format!("Failed to render request: {}", e)))?;
// Build sendable request
let options = SendableHttpRequestOptions {
timeout: params
.data
.get("timeout_ms")
.and_then(|v| v.as_u64())
.map(|ms| std::time::Duration::from_millis(ms)),
follow_redirects: params
.data
.get("follow_redirects")
.and_then(|v| v.as_bool())
.unwrap_or(false),
};
let sendable = SendableHttpRequest::from_http_request(&rendered_request, options)
.await
.map_err(|e| ActionError::Internal(format!("Failed to build request: {}", e)))?;
// Create event channel
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to drain events
let _event_handle = tokio::spawn(async move {
while event_rx.recv().await.is_some() {
// For now, just drain events
// In the future, we could log them or emit them to UI
}
});
// Send the request
let sender = ReqwestSender::new()
.map_err(|e| ActionError::Internal(format!("Failed to create HTTP client: {}", e)))?;
let response = sender
.send(sendable, event_tx)
.await
.map_err(|e| ActionError::Internal(format!("Failed to send request: {}", e)))?;
// Consume response body
let status = response.status;
let status_reason = response.status_reason.clone();
let headers = response.headers.clone();
let url = response.url.clone();
let (body_text, stats) = response
.text()
.await
.map_err(|e| ActionError::Internal(format!("Failed to read response body: {}", e)))?;
// Return success result with response data
Ok(ActionResult::Success {
data: Some(json!({
"status": status,
"statusReason": status_reason,
"headers": headers,
"body": body_text,
"contentLength": stats.size_decompressed,
"url": url,
})),
message: Some(format!("HTTP {}", status)),
})
})
}
}
/// Helper function to render templates in an HTTP request.
/// Copied from yaak-cli implementation.
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> Result<HttpRequest, String> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest {
url,
url_parameters,
headers,
body,
authentication,
..r.to_owned()
})
}

View File

@@ -1,11 +0,0 @@
//! Built-in action implementations for Yaak.
//!
//! This crate provides concrete implementations of built-in actions using
//! the yaak-actions framework. It depends on domain-specific crates like
//! yaak-http, yaak-models, yaak-plugins, etc.
pub mod dependencies;
pub mod http;
pub use dependencies::BuiltinActionDependencies;
pub use http::register_http_actions;

View File

@@ -1,15 +0,0 @@
[package]
name = "yaak-actions"
version = "0.1.0"
edition = "2021"
description = "Centralized action system for Yaak"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
ts-rs = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -1,14 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Availability status for an action.
*/
export type ActionAvailability = { "status": "available" } | { "status": "available-with-prompt",
/**
* Fields that will require prompting.
*/
prompt_fields: Array<string>, } | { "status": "unavailable",
/**
* Fields that are missing.
*/
missing_fields: Array<string>, } | { "status": "not-found" };

View File

@@ -1,13 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionId } from "./ActionId";
import type { ActionScope } from "./ActionScope";
/**
* Errors that can occur during action operations.
*/
export type ActionError = { "type": "not-found" } & ActionId | { "type": "disabled", action_id: ActionId, reason: string, } | { "type": "invalid-scope", expected: ActionScope, actual: ActionScope, } | { "type": "timeout" } & ActionId | { "type": "plugin-error" } & string | { "type": "validation-error" } & string | { "type": "permission-denied" } & string | { "type": "cancelled" } | { "type": "internal" } & string | { "type": "context-missing",
/**
* The context fields that are missing.
*/
missing_fields: Array<string>, } | { "type": "group-not-found" } & ActionGroupId | { "type": "group-already-exists" } & ActionGroupId;

View File

@@ -1,10 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Unique identifier for an action group.
*
* Format: `namespace:group-name`
* - Built-in: `yaak:export`
* - Plugin: `plugin.my-plugin:utilities`
*/
export type ActionGroupId = string;

View File

@@ -1,32 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionScope } from "./ActionScope";
/**
* Metadata about an action group.
*/
export type ActionGroupMetadata = {
/**
* Unique identifier for this group.
*/
id: ActionGroupId,
/**
* Display name for the group.
*/
name: string,
/**
* Optional description of the group's purpose.
*/
description: string | null,
/**
* Icon to display for the group.
*/
icon: string | null,
/**
* Sort order for displaying groups (lower = earlier).
*/
order: number,
/**
* Optional scope restriction (if set, group only appears in this scope).
*/
scope: ActionScope | null, };

View File

@@ -1,18 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where an action group was registered from.
*/
export type ActionGroupSource = { "type": "builtin" } | { "type": "plugin",
/**
* Plugin reference ID.
*/
ref_id: string,
/**
* Plugin name.
*/
name: string, } | { "type": "dynamic",
/**
* Source identifier.
*/
source_id: string, };

View File

@@ -1,16 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupMetadata } from "./ActionGroupMetadata";
import type { ActionMetadata } from "./ActionMetadata";
/**
* A group with its actions for UI rendering.
*/
export type ActionGroupWithActions = {
/**
* Group metadata.
*/
group: ActionGroupMetadata,
/**
* Actions in this group.
*/
actions: Array<ActionMetadata>, };

View File

@@ -1,10 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Unique identifier for an action.
*
* Format: `namespace:category:name`
* - Built-in: `yaak:http-request:send`
* - Plugin: `plugin.copy-curl:http-request:copy`
*/
export type ActionId = string;

View File

@@ -1,54 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionId } from "./ActionId";
import type { ActionScope } from "./ActionScope";
import type { RequiredContext } from "./RequiredContext";
/**
* Metadata about an action for discovery.
*/
export type ActionMetadata = {
/**
* Unique identifier for this action.
*/
id: ActionId,
/**
* Display label for the action.
*/
label: string,
/**
* Optional description of what the action does.
*/
description: string | null,
/**
* Icon name to display.
*/
icon: string | null,
/**
* The scope this action applies to.
*/
scope: ActionScope,
/**
* Keyboard shortcut (e.g., "Cmd+Enter").
*/
keyboardShortcut: string | null,
/**
* Whether the action requires a selection/target.
*/
requiresSelection: boolean,
/**
* Optional condition expression for when action is enabled.
*/
enabledCondition: string | null,
/**
* Optional group this action belongs to.
*/
groupId: ActionGroupId | null,
/**
* Sort order within a group (lower = earlier).
*/
order: number,
/**
* Context requirements for this action.
*/
requiredContext: RequiredContext, };

View File

@@ -1,10 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Parameters passed to action handlers.
*/
export type ActionParams = {
/**
* Arbitrary JSON parameters.
*/
data: unknown, };

View File

@@ -1,23 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InputPrompt } from "./InputPrompt";
/**
* Result of action execution.
*/
export type ActionResult = { "type": "success",
/**
* Optional data to return.
*/
data: unknown,
/**
* Optional message to display.
*/
message: string | null, } | { "type": "requires-input",
/**
* Prompt to show user.
*/
prompt: InputPrompt,
/**
* Continuation token.
*/
continuation_id: string, } | { "type": "cancelled" };

View File

@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The scope in which an action can be invoked.
*/
export type ActionScope = "global" | "http-request" | "websocket-request" | "grpc-request" | "workspace" | "folder" | "environment" | "cookie-jar";

View File

@@ -1,18 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where an action was registered from.
*/
export type ActionSource = { "type": "builtin" } | { "type": "plugin",
/**
* Plugin reference ID.
*/
ref_id: string,
/**
* Plugin name.
*/
name: string, } | { "type": "dynamic",
/**
* Source identifier.
*/
source_id: string, };

View File

@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The target entity for an action.
*/
export type ActionTarget = { "type": "none" } | { "type": "http-request", id: string, } | { "type": "websocket-request", id: string, } | { "type": "grpc-request", id: string, } | { "type": "workspace", id: string, } | { "type": "folder", id: string, } | { "type": "environment", id: string, } | { "type": "multiple", targets: Array<ActionTarget>, };

View File

@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How strictly a context field is required.
*/
export type ContextRequirement = "not-required" | "optional" | "required" | "required-with-prompt";

View File

@@ -1,27 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionTarget } from "./ActionTarget";
/**
* Current context state from the application.
*/
export type CurrentContext = {
/**
* Current workspace ID (if any).
*/
workspaceId: string | null,
/**
* Current environment ID (if any).
*/
environmentId: string | null,
/**
* Currently selected target (if any).
*/
target: ActionTarget | null,
/**
* Whether a window context is available.
*/
hasWindow: boolean,
/**
* Whether the context provider can prompt for missing fields.
*/
canPrompt: boolean, };

View File

@@ -1,7 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SelectOption } from "./SelectOption";
/**
* A prompt for user input.
*/
export type InputPrompt = { "type": "text", label: string, placeholder: string | null, default_value: string | null, } | { "type": "select", label: string, options: Array<SelectOption>, } | { "type": "confirm", label: string, };

View File

@@ -1,23 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ContextRequirement } from "./ContextRequirement";
/**
* Specifies what context fields an action requires.
*/
export type RequiredContext = {
/**
* Action requires a workspace to be active.
*/
workspace: ContextRequirement,
/**
* Action requires an environment to be selected.
*/
environment: ContextRequirement,
/**
* Action requires a specific target entity (request, folder, etc.).
*/
target: ContextRequirement,
/**
* Action requires a window context (for UI operations).
*/
window: ContextRequirement, };

View File

@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* An option in a select prompt.
*/
export type SelectOption = { label: string, value: string, };

View File

@@ -1,331 +0,0 @@
//! Action context types and context-aware filtering.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::ActionScope;
/// Specifies what context fields an action requires.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct RequiredContext {
/// Action requires a workspace to be active.
#[serde(default)]
pub workspace: ContextRequirement,
/// Action requires an environment to be selected.
#[serde(default)]
pub environment: ContextRequirement,
/// Action requires a specific target entity (request, folder, etc.).
#[serde(default)]
pub target: ContextRequirement,
/// Action requires a window context (for UI operations).
#[serde(default)]
pub window: ContextRequirement,
}
impl RequiredContext {
/// Action requires a target entity.
pub fn requires_target() -> Self {
Self {
target: ContextRequirement::Required,
..Default::default()
}
}
/// Action requires workspace and target.
pub fn requires_workspace_and_target() -> Self {
Self {
workspace: ContextRequirement::Required,
target: ContextRequirement::Required,
..Default::default()
}
}
/// Action works globally, no specific context needed.
pub fn global() -> Self {
Self::default()
}
/// Action requires target with prompt if missing.
pub fn requires_target_with_prompt() -> Self {
Self {
target: ContextRequirement::RequiredWithPrompt,
..Default::default()
}
}
/// Action requires environment with prompt if missing.
pub fn requires_environment_with_prompt() -> Self {
Self {
environment: ContextRequirement::RequiredWithPrompt,
..Default::default()
}
}
}
/// How strictly a context field is required.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum ContextRequirement {
/// Field is not needed.
#[default]
NotRequired,
/// Field is optional but will be used if available.
Optional,
/// Field must be present; action will fail without it.
Required,
/// Field must be present; prompt user to select if missing.
RequiredWithPrompt,
}
/// Current context state from the application.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct CurrentContext {
/// Current workspace ID (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Current environment ID (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
/// Currently selected target (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<ActionTarget>,
/// Whether a window context is available.
#[serde(default)]
pub has_window: bool,
/// Whether the context provider can prompt for missing fields.
#[serde(default)]
pub can_prompt: bool,
}
/// The target entity for an action.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionTarget {
/// No target.
None,
/// HTTP request target.
HttpRequest { id: String },
/// WebSocket request target.
WebsocketRequest { id: String },
/// gRPC request target.
GrpcRequest { id: String },
/// Workspace target.
Workspace { id: String },
/// Folder target.
Folder { id: String },
/// Environment target.
Environment { id: String },
/// Multiple targets.
Multiple { targets: Vec<ActionTarget> },
}
impl ActionTarget {
/// Get the scope this target corresponds to.
pub fn scope(&self) -> Option<ActionScope> {
match self {
Self::None => None,
Self::HttpRequest { .. } => Some(ActionScope::HttpRequest),
Self::WebsocketRequest { .. } => Some(ActionScope::WebsocketRequest),
Self::GrpcRequest { .. } => Some(ActionScope::GrpcRequest),
Self::Workspace { .. } => Some(ActionScope::Workspace),
Self::Folder { .. } => Some(ActionScope::Folder),
Self::Environment { .. } => Some(ActionScope::Environment),
Self::Multiple { .. } => None,
}
}
/// Get the ID of the target (if single target).
pub fn id(&self) -> Option<&str> {
match self {
Self::HttpRequest { id }
| Self::WebsocketRequest { id }
| Self::GrpcRequest { id }
| Self::Workspace { id }
| Self::Folder { id }
| Self::Environment { id } => Some(id),
Self::None | Self::Multiple { .. } => None,
}
}
}
/// Availability status for an action.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ActionAvailability {
/// Action is ready to execute.
Available,
/// Action can execute but will prompt for missing context.
AvailableWithPrompt {
/// Fields that will require prompting.
prompt_fields: Vec<String>,
},
/// Action cannot execute due to missing context.
Unavailable {
/// Fields that are missing.
missing_fields: Vec<String>,
},
/// Action not found in registry.
NotFound,
}
impl ActionAvailability {
/// Check if the action is available (possibly with prompts).
pub fn is_available(&self) -> bool {
matches!(self, Self::Available | Self::AvailableWithPrompt { .. })
}
/// Check if the action is immediately available without prompts.
pub fn is_immediately_available(&self) -> bool {
matches!(self, Self::Available)
}
}
/// Check if required context is satisfied by current context.
pub fn check_context_availability(
required: &RequiredContext,
current: &CurrentContext,
) -> ActionAvailability {
let mut missing_fields = Vec::new();
let mut prompt_fields = Vec::new();
// Check workspace
check_field(
"workspace",
current.workspace_id.is_some(),
&required.workspace,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check environment
check_field(
"environment",
current.environment_id.is_some(),
&required.environment,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check target
check_field(
"target",
current.target.is_some(),
&required.target,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check window
check_field(
"window",
current.has_window,
&required.window,
false, // Can't prompt for window
&mut missing_fields,
&mut prompt_fields,
);
if !missing_fields.is_empty() {
ActionAvailability::Unavailable { missing_fields }
} else if !prompt_fields.is_empty() {
ActionAvailability::AvailableWithPrompt { prompt_fields }
} else {
ActionAvailability::Available
}
}
fn check_field(
name: &str,
has_value: bool,
requirement: &ContextRequirement,
can_prompt: bool,
missing: &mut Vec<String>,
promptable: &mut Vec<String>,
) {
match requirement {
ContextRequirement::NotRequired | ContextRequirement::Optional => {}
ContextRequirement::Required => {
if !has_value {
missing.push(name.to_string());
}
}
ContextRequirement::RequiredWithPrompt => {
if !has_value {
if can_prompt {
promptable.push(name.to_string());
} else {
missing.push(name.to_string());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_available() {
let required = RequiredContext::requires_target();
let current = CurrentContext {
target: Some(ActionTarget::HttpRequest {
id: "123".to_string(),
}),
..Default::default()
};
let availability = check_context_availability(&required, &current);
assert!(matches!(availability, ActionAvailability::Available));
}
#[test]
fn test_context_missing() {
let required = RequiredContext::requires_target();
let current = CurrentContext::default();
let availability = check_context_availability(&required, &current);
assert!(matches!(
availability,
ActionAvailability::Unavailable { missing_fields } if missing_fields == vec!["target"]
));
}
#[test]
fn test_context_promptable() {
let required = RequiredContext::requires_target_with_prompt();
let current = CurrentContext {
can_prompt: true,
..Default::default()
};
let availability = check_context_availability(&required, &current);
assert!(matches!(
availability,
ActionAvailability::AvailableWithPrompt { prompt_fields } if prompt_fields == vec!["target"]
));
}
}

View File

@@ -1,131 +0,0 @@
//! Error types for the action system.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
use crate::{ActionGroupId, ActionId};
/// Errors that can occur during action operations.
#[derive(Debug, Error, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionError {
/// Action not found in registry.
#[error("Action not found: {0}")]
NotFound(ActionId),
/// Action is disabled in current context.
#[error("Action is disabled: {action_id} - {reason}")]
Disabled { action_id: ActionId, reason: String },
/// Invalid scope for the action.
#[error("Invalid scope: expected {expected:?}, got {actual:?}")]
InvalidScope {
expected: crate::ActionScope,
actual: crate::ActionScope,
},
/// Action execution timed out.
#[error("Action timed out: {0}")]
Timeout(ActionId),
/// Error from plugin execution.
#[error("Plugin error: {0}")]
PluginError(String),
/// Validation error in action parameters.
#[error("Validation error: {0}")]
ValidationError(String),
/// Permission denied for action.
#[error("Permission denied: {0}")]
PermissionDenied(String),
/// Action was cancelled by user.
#[error("Action cancelled by user")]
Cancelled,
/// Internal error.
#[error("Internal error: {0}")]
Internal(String),
/// Required context is missing.
#[error("Required context missing: {missing_fields:?}")]
ContextMissing {
/// The context fields that are missing.
missing_fields: Vec<String>,
},
/// Action group not found.
#[error("Group not found: {0}")]
GroupNotFound(ActionGroupId),
/// Action group already exists.
#[error("Group already exists: {0}")]
GroupAlreadyExists(ActionGroupId),
}
impl ActionError {
/// Get a user-friendly error message.
pub fn user_message(&self) -> String {
match self {
Self::NotFound(id) => format!("Action '{}' is not available", id),
Self::Disabled { reason, .. } => reason.clone(),
Self::InvalidScope { expected, actual } => {
format!("Action requires {:?} scope, but got {:?}", expected, actual)
}
Self::Timeout(_) => "The operation took too long and was cancelled".into(),
Self::PluginError(msg) => format!("Plugin error: {}", msg),
Self::ValidationError(msg) => format!("Invalid input: {}", msg),
Self::PermissionDenied(resource) => format!("Permission denied for {}", resource),
Self::Cancelled => "Operation was cancelled".into(),
Self::Internal(_) => "An unexpected error occurred".into(),
Self::ContextMissing { missing_fields } => {
format!("Missing required context: {}", missing_fields.join(", "))
}
Self::GroupNotFound(id) => format!("Action group '{}' not found", id),
Self::GroupAlreadyExists(id) => format!("Action group '{}' already exists", id),
}
}
/// Whether this error should be reported to telemetry.
pub fn is_reportable(&self) -> bool {
matches!(self, Self::Internal(_) | Self::PluginError(_))
}
/// Whether this error can potentially be resolved by user interaction.
pub fn is_promptable(&self) -> bool {
matches!(self, Self::ContextMissing { .. })
}
/// Whether this is a user-initiated cancellation.
pub fn is_cancelled(&self) -> bool {
matches!(self, Self::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_messages() {
let err = ActionError::ContextMissing {
missing_fields: vec!["workspace".into()],
};
assert_eq!(err.user_message(), "Missing required context: workspace");
assert!(err.is_promptable());
assert!(!err.is_cancelled());
let cancelled = ActionError::Cancelled;
assert!(cancelled.is_cancelled());
assert!(!cancelled.is_promptable());
let not_found = ActionError::NotFound(ActionId::builtin("test", "action"));
assert_eq!(
not_found.user_message(),
"Action 'yaak:test:action' is not available"
);
}
}

View File

@@ -1,606 +0,0 @@
//! Action executor - central hub for action registration and invocation.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::{
check_context_availability, ActionAvailability, ActionError, ActionGroupId,
ActionGroupMetadata, ActionGroupSource, ActionGroupWithActions, ActionHandler, ActionId,
ActionMetadata, ActionParams, ActionResult, ActionScope, ActionSource, CurrentContext,
RegisteredActionGroup,
};
/// Options for listing actions.
#[derive(Clone, Debug, Default)]
pub struct ListActionsOptions {
/// Filter by scope.
pub scope: Option<ActionScope>,
/// Filter by group.
pub group_id: Option<ActionGroupId>,
/// Search term for label/description.
pub search: Option<String>,
}
/// A registered action with its handler.
struct RegisteredAction {
/// Action metadata.
metadata: ActionMetadata,
/// Where the action was registered from.
source: ActionSource,
/// The handler for this action.
handler: Arc<dyn ActionHandler>,
}
/// Central hub for action registration and invocation.
///
/// The executor owns all action metadata and handlers, ensuring every
/// registered action has a handler by construction.
pub struct ActionExecutor {
/// All registered actions indexed by ID.
actions: RwLock<HashMap<ActionId, RegisteredAction>>,
/// Actions indexed by scope for efficient filtering.
scope_index: RwLock<HashMap<ActionScope, Vec<ActionId>>>,
/// All registered groups indexed by ID.
groups: RwLock<HashMap<ActionGroupId, RegisteredActionGroup>>,
}
impl Default for ActionExecutor {
fn default() -> Self {
Self::new()
}
}
impl ActionExecutor {
/// Create a new empty executor.
pub fn new() -> Self {
Self {
actions: RwLock::new(HashMap::new()),
scope_index: RwLock::new(HashMap::new()),
groups: RwLock::new(HashMap::new()),
}
}
// ─────────────────────────────────────────────────────────────────────────
// Action Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register an action with its handler.
///
/// Every action must have a handler - this is enforced by the API.
pub async fn register<H: ActionHandler + 'static>(
&self,
metadata: ActionMetadata,
source: ActionSource,
handler: H,
) -> Result<ActionId, ActionError> {
let id = metadata.id.clone();
let scope = metadata.scope.clone();
let action = RegisteredAction {
metadata,
source,
handler: Arc::new(handler),
};
// Insert action
{
let mut actions = self.actions.write().await;
actions.insert(id.clone(), action);
}
// Update scope index
{
let mut index = self.scope_index.write().await;
index.entry(scope).or_default().push(id.clone());
}
Ok(id)
}
/// Unregister an action.
pub async fn unregister(&self, id: &ActionId) -> Result<(), ActionError> {
let mut actions = self.actions.write().await;
let action = actions
.remove(id)
.ok_or_else(|| ActionError::NotFound(id.clone()))?;
// Update scope index
{
let mut index = self.scope_index.write().await;
if let Some(ids) = index.get_mut(&action.metadata.scope) {
ids.retain(|i| i != id);
}
}
// Remove from group if assigned
if let Some(group_id) = &action.metadata.group_id {
let mut groups = self.groups.write().await;
if let Some(group) = groups.get_mut(group_id) {
group.action_ids.retain(|i| i != id);
}
}
Ok(())
}
/// Unregister all actions from a specific source.
pub async fn unregister_source(&self, source_id: &str) -> Vec<ActionId> {
let actions_to_remove: Vec<ActionId> = {
let actions = self.actions.read().await;
actions
.iter()
.filter(|(_, a)| match &a.source {
ActionSource::Plugin { ref_id, .. } => ref_id == source_id,
ActionSource::Dynamic {
source_id: sid, ..
} => sid == source_id,
ActionSource::Builtin => false,
})
.map(|(id, _)| id.clone())
.collect()
};
for id in &actions_to_remove {
let _ = self.unregister(id).await;
}
actions_to_remove
}
// ─────────────────────────────────────────────────────────────────────────
// Action Invocation
// ─────────────────────────────────────────────────────────────────────────
/// Invoke an action with the given context and parameters.
///
/// This will:
/// 1. Look up the action metadata
/// 2. Check context availability
/// 3. Execute the handler
pub async fn invoke(
&self,
action_id: &ActionId,
context: CurrentContext,
params: ActionParams,
) -> Result<ActionResult, ActionError> {
// Get action and handler
let (metadata, handler) = {
let actions = self.actions.read().await;
let action = actions
.get(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
(action.metadata.clone(), action.handler.clone())
};
// Check context availability
let availability = check_context_availability(&metadata.required_context, &context);
match availability {
ActionAvailability::Available | ActionAvailability::AvailableWithPrompt { .. } => {
// Context is satisfied, proceed with execution
}
ActionAvailability::Unavailable { missing_fields } => {
return Err(ActionError::ContextMissing { missing_fields });
}
ActionAvailability::NotFound => {
return Err(ActionError::NotFound(action_id.clone()));
}
}
// Execute handler
handler.handle(context, params).await
}
/// Invoke an action, skipping context validation.
///
/// Use this when you've already validated the context externally.
pub async fn invoke_unchecked(
&self,
action_id: &ActionId,
context: CurrentContext,
params: ActionParams,
) -> Result<ActionResult, ActionError> {
// Get handler
let handler = {
let actions = self.actions.read().await;
let action = actions
.get(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
action.handler.clone()
};
// Execute handler
handler.handle(context, params).await
}
// ─────────────────────────────────────────────────────────────────────────
// Action Queries
// ─────────────────────────────────────────────────────────────────────────
/// Get action metadata by ID.
pub async fn get(&self, id: &ActionId) -> Option<ActionMetadata> {
let actions = self.actions.read().await;
actions.get(id).map(|a| a.metadata.clone())
}
/// List all actions, optionally filtered.
pub async fn list(&self, options: ListActionsOptions) -> Vec<ActionMetadata> {
let actions = self.actions.read().await;
let mut result: Vec<_> = actions
.values()
.filter(|a| {
// Scope filter
if let Some(scope) = &options.scope {
if &a.metadata.scope != scope {
return false;
}
}
// Group filter
if let Some(group_id) = &options.group_id {
if a.metadata.group_id.as_ref() != Some(group_id) {
return false;
}
}
// Search filter
if let Some(search) = &options.search {
let search = search.to_lowercase();
let matches_label = a.metadata.label.to_lowercase().contains(&search);
let matches_desc = a
.metadata
.description
.as_ref()
.map(|d| d.to_lowercase().contains(&search))
.unwrap_or(false);
if !matches_label && !matches_desc {
return false;
}
}
true
})
.map(|a| a.metadata.clone())
.collect();
// Sort by order then label
result.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
result
}
/// List actions available in the given context.
pub async fn list_available(
&self,
context: &CurrentContext,
options: ListActionsOptions,
) -> Vec<(ActionMetadata, ActionAvailability)> {
let all_actions = self.list(options).await;
all_actions
.into_iter()
.map(|action| {
let availability =
check_context_availability(&action.required_context, context);
(action, availability)
})
.filter(|(_, availability)| availability.is_available())
.collect()
}
/// Get availability status for a specific action.
pub async fn get_availability(
&self,
id: &ActionId,
context: &CurrentContext,
) -> ActionAvailability {
let actions = self.actions.read().await;
match actions.get(id) {
Some(action) => {
check_context_availability(&action.metadata.required_context, context)
}
None => ActionAvailability::NotFound,
}
}
// ─────────────────────────────────────────────────────────────────────────
// Group Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register an action group.
pub async fn register_group(
&self,
metadata: ActionGroupMetadata,
source: ActionGroupSource,
) -> Result<ActionGroupId, ActionError> {
let id = metadata.id.clone();
let mut groups = self.groups.write().await;
if groups.contains_key(&id) {
return Err(ActionError::GroupAlreadyExists(id));
}
groups.insert(
id.clone(),
RegisteredActionGroup {
metadata,
action_ids: Vec::new(),
source,
},
);
Ok(id)
}
/// Unregister a group (does not unregister its actions).
pub async fn unregister_group(&self, id: &ActionGroupId) -> Result<(), ActionError> {
let mut groups = self.groups.write().await;
groups
.remove(id)
.ok_or_else(|| ActionError::GroupNotFound(id.clone()))?;
Ok(())
}
/// Add an action to a group.
pub async fn add_to_group(
&self,
action_id: &ActionId,
group_id: &ActionGroupId,
) -> Result<(), ActionError> {
// Update action's group_id
{
let mut actions = self.actions.write().await;
let action = actions
.get_mut(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
action.metadata.group_id = Some(group_id.clone());
}
// Add to group's action list
{
let mut groups = self.groups.write().await;
let group = groups
.get_mut(group_id)
.ok_or_else(|| ActionError::GroupNotFound(group_id.clone()))?;
if !group.action_ids.contains(action_id) {
group.action_ids.push(action_id.clone());
}
}
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────
// Group Queries
// ─────────────────────────────────────────────────────────────────────────
/// Get a group by ID.
pub async fn get_group(&self, id: &ActionGroupId) -> Option<ActionGroupMetadata> {
let groups = self.groups.read().await;
groups.get(id).map(|g| g.metadata.clone())
}
/// List all groups, optionally filtered by scope.
pub async fn list_groups(&self, scope: Option<ActionScope>) -> Vec<ActionGroupMetadata> {
let groups = self.groups.read().await;
let mut result: Vec<_> = groups
.values()
.filter(|g| {
scope.as_ref().map_or(true, |s| {
g.metadata.scope.as_ref().map_or(true, |gs| gs == s)
})
})
.map(|g| g.metadata.clone())
.collect();
result.sort_by_key(|g| g.order);
result
}
/// List all actions in a specific group.
pub async fn list_by_group(&self, group_id: &ActionGroupId) -> Vec<ActionMetadata> {
let groups = self.groups.read().await;
let actions = self.actions.read().await;
groups
.get(group_id)
.map(|group| {
let mut result: Vec<_> = group
.action_ids
.iter()
.filter_map(|id| actions.get(id).map(|a| a.metadata.clone()))
.collect();
result.sort_by_key(|a| a.order);
result
})
.unwrap_or_default()
}
/// Get actions organized by their groups.
pub async fn list_grouped(&self, scope: Option<ActionScope>) -> Vec<ActionGroupWithActions> {
let group_list = self.list_groups(scope).await;
let mut result = Vec::new();
for group in group_list {
let actions = self.list_by_group(&group.id).await;
result.push(ActionGroupWithActions { group, actions });
}
result
}
// ─────────────────────────────────────────────────────────────────────────
// Built-in Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register all built-in groups.
pub async fn register_builtin_groups(&self) -> Result<(), ActionError> {
for group in crate::groups::builtin::all() {
self.register_group(group, ActionGroupSource::Builtin).await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{handler_fn, RequiredContext};
async fn create_test_executor() -> ActionExecutor {
let executor = ActionExecutor::new();
executor
.register(
ActionMetadata {
id: ActionId::builtin("test", "echo"),
label: "Echo".to_string(),
description: None,
icon: None,
scope: ActionScope::Global,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, params| async move {
let msg: String = params.get("message").unwrap_or_default();
Ok(ActionResult::with_message(msg))
}),
)
.await
.unwrap();
executor
}
#[tokio::test]
async fn test_register_and_invoke() {
let executor = create_test_executor().await;
let action_id = ActionId::builtin("test", "echo");
let params = ActionParams::from_json(serde_json::json!({
"message": "Hello, World!"
}));
let result = executor
.invoke(&action_id, CurrentContext::default(), params)
.await
.unwrap();
match result {
ActionResult::Success { message, .. } => {
assert_eq!(message, Some("Hello, World!".to_string()));
}
_ => panic!("Expected Success result"),
}
}
#[tokio::test]
async fn test_invoke_not_found() {
let executor = ActionExecutor::new();
let action_id = ActionId::builtin("test", "unknown");
let result = executor
.invoke(&action_id, CurrentContext::default(), ActionParams::empty())
.await;
assert!(matches!(result, Err(ActionError::NotFound(_))));
}
#[tokio::test]
async fn test_list_by_scope() {
let executor = ActionExecutor::new();
executor
.register(
ActionMetadata {
id: ActionId::builtin("global", "one"),
label: "Global One".to_string(),
description: None,
icon: None,
scope: ActionScope::Global,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
)
.await
.unwrap();
executor
.register(
ActionMetadata {
id: ActionId::builtin("http", "one"),
label: "HTTP One".to_string(),
description: None,
icon: None,
scope: ActionScope::HttpRequest,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
)
.await
.unwrap();
let global_actions = executor
.list(ListActionsOptions {
scope: Some(ActionScope::Global),
..Default::default()
})
.await;
assert_eq!(global_actions.len(), 1);
let http_actions = executor
.list(ListActionsOptions {
scope: Some(ActionScope::HttpRequest),
..Default::default()
})
.await;
assert_eq!(http_actions.len(), 1);
}
#[tokio::test]
async fn test_groups() {
let executor = ActionExecutor::new();
executor.register_builtin_groups().await.unwrap();
let groups = executor.list_groups(None).await;
assert!(!groups.is_empty());
let export_group = executor.get_group(&ActionGroupId::builtin("export")).await;
assert!(export_group.is_some());
assert_eq!(export_group.unwrap().name, "Export");
}
#[tokio::test]
async fn test_unregister() {
let executor = create_test_executor().await;
let action_id = ActionId::builtin("test", "echo");
assert!(executor.get(&action_id).await.is_some());
executor.unregister(&action_id).await.unwrap();
assert!(executor.get(&action_id).await.is_none());
}
}

View File

@@ -1,208 +0,0 @@
//! Action group types and management.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{ActionId, ActionMetadata, ActionScope};
/// Unique identifier for an action group.
///
/// Format: `namespace:group-name`
/// - Built-in: `yaak:export`
/// - Plugin: `plugin.my-plugin:utilities`
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionGroupId(pub String);
impl ActionGroupId {
/// Create a namespaced group ID.
pub fn new(namespace: &str, name: &str) -> Self {
Self(format!("{}:{}", namespace, name))
}
/// Create ID for built-in groups.
pub fn builtin(name: &str) -> Self {
Self::new("yaak", name)
}
/// Create ID for plugin groups.
pub fn plugin(plugin_ref_id: &str, name: &str) -> Self {
Self::new(&format!("plugin.{}", plugin_ref_id), name)
}
/// Get the raw string value.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ActionGroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Metadata about an action group.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionGroupMetadata {
/// Unique identifier for this group.
pub id: ActionGroupId,
/// Display name for the group.
pub name: String,
/// Optional description of the group's purpose.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Icon to display for the group.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Sort order for displaying groups (lower = earlier).
#[serde(default)]
pub order: i32,
/// Optional scope restriction (if set, group only appears in this scope).
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<ActionScope>,
}
/// Where an action group was registered from.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionGroupSource {
/// Built into Yaak core.
Builtin,
/// Registered by a plugin.
Plugin {
/// Plugin reference ID.
ref_id: String,
/// Plugin name.
name: String,
},
/// Registered at runtime.
Dynamic {
/// Source identifier.
source_id: String,
},
}
/// A registered action group with its actions.
#[derive(Clone, Debug)]
pub struct RegisteredActionGroup {
/// Group metadata.
pub metadata: ActionGroupMetadata,
/// IDs of actions in this group (ordered by action's order field).
pub action_ids: Vec<ActionId>,
/// Where the group was registered from.
pub source: ActionGroupSource,
}
/// A group with its actions for UI rendering.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionGroupWithActions {
/// Group metadata.
pub group: ActionGroupMetadata,
/// Actions in this group.
pub actions: Vec<ActionMetadata>,
}
/// Built-in action group definitions.
pub mod builtin {
use super::*;
/// Export group - export and copy actions.
pub fn export() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("export"),
name: "Export".into(),
description: Some("Export and copy actions".into()),
icon: Some("download".into()),
order: 100,
scope: None,
}
}
/// Code generation group.
pub fn code_generation() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("code-generation"),
name: "Code Generation".into(),
description: Some("Generate code snippets from requests".into()),
icon: Some("code".into()),
order: 200,
scope: Some(ActionScope::HttpRequest),
}
}
/// Send group - request sending actions.
pub fn send() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("send"),
name: "Send".into(),
description: Some("Actions for sending requests".into()),
icon: Some("play".into()),
order: 50,
scope: Some(ActionScope::HttpRequest),
}
}
/// Import group.
pub fn import() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("import"),
name: "Import".into(),
description: Some("Import data from files".into()),
icon: Some("upload".into()),
order: 150,
scope: None,
}
}
/// Workspace management group.
pub fn workspace() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("workspace"),
name: "Workspace".into(),
description: Some("Workspace management actions".into()),
icon: Some("folder".into()),
order: 300,
scope: Some(ActionScope::Workspace),
}
}
/// Get all built-in group definitions.
pub fn all() -> Vec<ActionGroupMetadata> {
vec![send(), export(), import(), code_generation(), workspace()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_group_id_creation() {
let id = ActionGroupId::builtin("export");
assert_eq!(id.as_str(), "yaak:export");
let plugin_id = ActionGroupId::plugin("my-plugin", "utilities");
assert_eq!(plugin_id.as_str(), "plugin.my-plugin:utilities");
}
#[test]
fn test_builtin_groups() {
let groups = builtin::all();
assert!(!groups.is_empty());
assert!(groups.iter().any(|g| g.id == ActionGroupId::builtin("export")));
}
}

View File

@@ -1,103 +0,0 @@
//! Action handler types and execution.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::{ActionError, ActionParams, ActionResult, CurrentContext};
/// A boxed future for async action handlers.
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Function signature for action handlers.
pub type ActionHandlerFn = Arc<
dyn Fn(CurrentContext, ActionParams) -> BoxFuture<'static, Result<ActionResult, ActionError>>
+ Send
+ Sync,
>;
/// Trait for types that can handle action invocations.
pub trait ActionHandler: Send + Sync {
/// Execute the action with the given context and parameters.
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> BoxFuture<'static, Result<ActionResult, ActionError>>;
}
/// Wrapper to create an ActionHandler from a function.
pub struct FnHandler<F>(pub F);
impl<F, Fut> ActionHandler for FnHandler<F>
where
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
{
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> BoxFuture<'static, Result<ActionResult, ActionError>> {
Box::pin((self.0)(context, params))
}
}
/// Create an action handler from an async function.
///
/// # Example
/// ```ignore
/// let handler = handler_fn(|ctx, params| async move {
/// Ok(ActionResult::ok())
/// });
/// ```
pub fn handler_fn<F, Fut>(f: F) -> FnHandler<F>
where
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
{
FnHandler(f)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_handler_fn() {
let handler = handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) });
let result = handler
.handle(CurrentContext::default(), ActionParams::empty())
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handler_with_params() {
let handler = handler_fn(|_ctx, params| async move {
let name: Option<String> = params.get("name");
Ok(ActionResult::with_message(format!(
"Hello, {}!",
name.unwrap_or_else(|| "World".to_string())
)))
});
let params = ActionParams::from_json(serde_json::json!({
"name": "Yaak"
}));
let result = handler
.handle(CurrentContext::default(), params)
.await
.unwrap();
match result {
ActionResult::Success { message, .. } => {
assert_eq!(message, Some("Hello, Yaak!".to_string()));
}
_ => panic!("Expected Success result"),
}
}
}

View File

@@ -1,18 +0,0 @@
//! Centralized action system for Yaak.
//!
//! This crate provides a unified hub for registering and invoking actions
//! across all entry points: plugins, Tauri desktop app, CLI, deep links, and MCP server.
mod context;
mod error;
mod executor;
mod groups;
mod handler;
mod types;
pub use context::*;
pub use error::*;
pub use executor::*;
pub use groups::*;
pub use handler::*;
pub use types::*;

View File

@@ -1,273 +0,0 @@
//! Core types for the action system.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{ActionGroupId, RequiredContext};
/// Unique identifier for an action.
///
/// Format: `namespace:category:name`
/// - Built-in: `yaak:http-request:send`
/// - Plugin: `plugin.copy-curl:http-request:copy`
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionId(pub String);
impl ActionId {
/// Create a namespaced action ID.
pub fn new(namespace: &str, category: &str, name: &str) -> Self {
Self(format!("{}:{}:{}", namespace, category, name))
}
/// Create ID for built-in actions.
pub fn builtin(category: &str, name: &str) -> Self {
Self::new("yaak", category, name)
}
/// Create ID for plugin actions.
pub fn plugin(plugin_ref_id: &str, category: &str, name: &str) -> Self {
Self::new(&format!("plugin.{}", plugin_ref_id), category, name)
}
/// Get the raw string value.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ActionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The scope in which an action can be invoked.
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum ActionScope {
/// Global actions available everywhere.
Global,
/// Actions on HTTP requests.
HttpRequest,
/// Actions on WebSocket requests.
WebsocketRequest,
/// Actions on gRPC requests.
GrpcRequest,
/// Actions on workspaces.
Workspace,
/// Actions on folders.
Folder,
/// Actions on environments.
Environment,
/// Actions on cookie jars.
CookieJar,
}
/// Metadata about an action for discovery.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionMetadata {
/// Unique identifier for this action.
pub id: ActionId,
/// Display label for the action.
pub label: String,
/// Optional description of what the action does.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Icon name to display.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// The scope this action applies to.
pub scope: ActionScope,
/// Keyboard shortcut (e.g., "Cmd+Enter").
#[serde(skip_serializing_if = "Option::is_none")]
pub keyboard_shortcut: Option<String>,
/// Whether the action requires a selection/target.
#[serde(default)]
pub requires_selection: bool,
/// Optional condition expression for when action is enabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_condition: Option<String>,
/// Optional group this action belongs to.
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<ActionGroupId>,
/// Sort order within a group (lower = earlier).
#[serde(default)]
pub order: i32,
/// Context requirements for this action.
#[serde(default)]
pub required_context: RequiredContext,
}
/// Where an action was registered from.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionSource {
/// Built into Yaak core.
Builtin,
/// Registered by a plugin.
Plugin {
/// Plugin reference ID.
ref_id: String,
/// Plugin name.
name: String,
},
/// Registered at runtime (e.g., by MCP tools).
Dynamic {
/// Source identifier.
source_id: String,
},
}
/// Parameters passed to action handlers.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionParams {
/// Arbitrary JSON parameters.
#[serde(default)]
#[ts(type = "unknown")]
pub data: serde_json::Value,
}
impl ActionParams {
/// Create empty params.
pub fn empty() -> Self {
Self {
data: serde_json::Value::Null,
}
}
/// Create params from a JSON value.
pub fn from_json(data: serde_json::Value) -> Self {
Self { data }
}
/// Get a typed value from the params.
pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.data
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
}
/// Result of action execution.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionResult {
/// Action completed successfully.
Success {
/// Optional data to return.
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(type = "unknown")]
data: Option<serde_json::Value>,
/// Optional message to display.
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
/// Action requires user input to continue.
RequiresInput {
/// Prompt to show user.
prompt: InputPrompt,
/// Continuation token.
continuation_id: String,
},
/// Action was cancelled by the user.
Cancelled,
}
impl ActionResult {
/// Create a success result with no data.
pub fn ok() -> Self {
Self::Success {
data: None,
message: None,
}
}
/// Create a success result with a message.
pub fn with_message(message: impl Into<String>) -> Self {
Self::Success {
data: None,
message: Some(message.into()),
}
}
/// Create a success result with data.
pub fn with_data(data: serde_json::Value) -> Self {
Self::Success {
data: Some(data),
message: None,
}
}
}
/// A prompt for user input.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum InputPrompt {
/// Text input prompt.
Text {
label: String,
placeholder: Option<String>,
default_value: Option<String>,
},
/// Selection prompt.
Select {
label: String,
options: Vec<SelectOption>,
},
/// Confirmation prompt.
Confirm { label: String },
}
/// An option in a select prompt.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SelectOption {
pub label: String,
pub value: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_id_creation() {
let id = ActionId::builtin("http-request", "send");
assert_eq!(id.as_str(), "yaak:http-request:send");
let plugin_id = ActionId::plugin("copy-curl", "http-request", "copy");
assert_eq!(plugin_id.as_str(), "plugin.copy-curl:http-request:copy");
}
#[test]
fn test_action_params() {
let params = ActionParams::from_json(serde_json::json!({
"name": "test",
"count": 42
}));
assert_eq!(params.get::<String>("name"), Some("test".to_string()));
assert_eq!(params.get::<i32>("count"), Some(42));
assert_eq!(params.get::<String>("missing"), None);
}
}

View File

@@ -6,7 +6,7 @@ publish = false
[dependencies]
chrono = { workspace = true, features = ["serde"] }
git2 = { version = "0.20.0", features = ["vendored-libgit2", "vendored-openssl"] }
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -15,8 +15,8 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };

View File

@@ -4,6 +4,7 @@ import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
import { queryClient } from '@yaakapp/app/lib/queryClient';
import { useMemo } from 'react';
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
import { showToast } from '@yaakapp/app/lib/toast';
export * from './bindings/gen_git';
export * from './bindings/gen_models';
@@ -13,11 +14,20 @@ export interface GitCredentials {
password: string;
}
export type DivergedStrategy = 'force_reset' | 'merge' | 'cancel';
export type UncommittedChangesStrategy = 'reset' | 'cancel';
export interface GitCallbacks {
addRemote: () => Promise<GitRemote | null>;
promptCredentials: (
result: Extract<PushResult, { type: 'needs_credentials' }>,
) => Promise<GitCredentials | null>;
promptDiverged: (
result: Extract<PullResult, { type: 'diverged' }>,
) => Promise<DivergedStrategy>;
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
forceSync: () => Promise<void>;
}
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
@@ -69,6 +79,15 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
return invoke<PushResult>('cmd_git_push', { dir });
};
const handleError = (err: unknown) => {
showToast({
id: `${err}`,
message: `${err}`,
color: 'danger',
timeout: 5000,
});
}
return {
init: createFastMutation<void, string, void>({
mutationKey: ['git', 'init'],
@@ -133,10 +152,9 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
},
onSuccess,
}),
fetchAll: createFastMutation<string, string, void>({
mutationKey: ['git', 'checkout', dir],
fetchAll: createFastMutation<void, string, void>({
mutationKey: ['git', 'fetch_all', dir],
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
onSuccess,
}),
push: createFastMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
@@ -147,20 +165,51 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationKey: ['git', 'pull', dir],
async mutationFn() {
const result = await invoke<PullResult>('cmd_git_pull', { dir });
if (result.type !== 'needs_credentials') return result;
// Needs credentials, prompt for them
const creds = await callbacks.promptCredentials(result);
if (creds == null) throw new Error('Canceled');
if (result.type === 'needs_credentials') {
const creds = await callbacks.promptCredentials(result);
if (creds == null) throw new Error('Canceled');
await invoke('cmd_git_add_credential', {
remoteUrl: result.url,
username: creds.username,
password: creds.password,
});
await invoke('cmd_git_add_credential', {
remoteUrl: result.url,
username: creds.username,
password: creds.password,
});
// Pull again
return invoke<PullResult>('cmd_git_pull', { dir });
// Pull again after credentials
return invoke<PullResult>('cmd_git_pull', { dir });
}
if (result.type === 'uncommitted_changes') {
callbacks.promptUncommittedChanges().then(async (strategy) => {
if (strategy === 'cancel') return;
await invoke('cmd_git_reset_changes', { dir });
return invoke<PullResult>('cmd_git_pull', { dir });
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
}
if (result.type === 'diverged') {
callbacks.promptDiverged(result).then((strategy) => {
if (strategy === 'cancel') return;
if (strategy === 'force_reset') {
return invoke<PullResult>('cmd_git_pull_force_reset', {
dir,
remote: result.remote,
branch: result.branch,
});
}
return invoke<PullResult>('cmd_git_pull_merge', {
dir,
remote: result.remote,
branch: result.branch,
});
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
}
return result;
},
onSuccess,
}),
@@ -169,6 +218,11 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
onSuccess,
}),
resetChanges: createFastMutation<void, string, void>({
mutationKey: ['git', 'reset-changes', dir],
mutationFn: () => invoke('cmd_git_reset_changes', { dir }),
onSuccess,
}),
} as const;
};

View File

@@ -13,6 +13,7 @@ mod pull;
mod push;
mod remotes;
mod repository;
mod reset;
mod status;
mod unstage;
mod util;
@@ -29,8 +30,9 @@ pub use credential::git_add_credential;
pub use fetch::git_fetch_all;
pub use init::git_init;
pub use log::{GitCommit, git_log};
pub use pull::{PullResult, git_pull};
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
pub use push::{PushResult, git_push};
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
pub use reset::git_reset_changes;
pub use status::{GitStatusSummary, git_status};
pub use unstage::git_unstage;

View File

@@ -15,9 +15,23 @@ pub enum PullResult {
Success { message: String },
UpToDate,
NeedsCredentials { url: String, error: Option<String> },
Diverged { remote: String, branch: String },
UncommittedChanges,
}
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false).include_untracked(false);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
}
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
if has_uncommitted_changes(dir)? {
return Ok(PullResult::UncommittedChanges);
}
// Extract all git2 data before any await points (git2 types are not Send)
let (branch_name, remote_name, remote_url) = {
let repo = open_repo(dir)?;
@@ -56,6 +70,13 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
}
if !out.status.success() {
let combined_lower = combined.to_lowercase();
if combined_lower.contains("cannot fast-forward")
|| combined_lower.contains("not possible to fast-forward")
|| combined_lower.contains("diverged")
{
return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });
}
return Err(GenericError(format!("Failed to pull {combined}")));
}
@@ -66,6 +87,65 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) })
}
pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
// Step 1: fetch the remote
let fetch_out = new_binary_command(dir)
.await?
.args(["fetch", remote])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
if !fetch_out.status.success() {
let stderr = String::from_utf8_lossy(&fetch_out.stderr);
return Err(GenericError(format!("Failed to fetch: {stderr}")));
}
// Step 2: reset --hard to remote/branch
let ref_name = format!("{}/{}", remote, branch);
let reset_out = new_binary_command(dir)
.await?
.args(["reset", "--hard", &ref_name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
if !reset_out.status.success() {
let stderr = String::from_utf8_lossy(&reset_out.stderr);
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
}
Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) })
}
pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
let out = new_binary_command(dir)
.await?
.args(["pull", "--no-rebase", remote, branch])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await
.map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{}{}", stdout, stderr);
info!("Pull merge status={} {combined}", out.status);
if !out.status.success() {
if combined.to_lowercase().contains("conflict") {
return Err(GenericError(
"Merge conflicts detected. Please resolve them manually.".to_string(),
));
}
return Err(GenericError(format!("Failed to merge pull: {}", combined.trim())));
}
Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) })
}
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
// let repo = open_repo(dir)?;
//

View File

@@ -0,0 +1,20 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use std::path::Path;
pub async fn git_reset_changes(dir: &Path) -> Result<()> {
let out = new_binary_command(dir)
.await?
.args(["reset", "--hard", "HEAD"])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
}
Ok(())
}

View File

@@ -18,6 +18,8 @@ pub struct GitStatusSummary {
pub origins: Vec<String>,
pub local_branches: Vec<String>,
pub remote_branches: Vec<String>,
pub ahead: u32,
pub behind: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
@@ -160,6 +162,18 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
let local_branches = local_branch_names(&repo)?;
let remote_branches = remote_branch_names(&repo)?;
// Compute ahead/behind relative to remote tracking branch
let (ahead, behind) = (|| -> Option<(usize, usize)> {
let head = repo.head().ok()?;
let local_oid = head.target()?;
let branch_name = head.shorthand()?;
let upstream_ref =
repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?;
let upstream_oid = upstream_ref.get().target()?;
repo.graph_ahead_behind(local_oid, upstream_oid).ok()
})()
.unwrap_or((0, 0));
Ok(GitStatusSummary {
entries,
origins,
@@ -168,5 +182,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
head_ref_shorthand,
local_branches,
remote_branches,
ahead: ahead as u32,
behind: behind as u32,
})
}

View File

@@ -8,7 +8,7 @@ publish = false
async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] }
async-trait = "0.1"
brotli = "7"
bytes = "1.5.0"
bytes = "1.11.1"
cookie = "0.18.1"
flate2 = "1"
futures-util = "0.3"

View File

@@ -168,6 +168,7 @@ impl<S: HttpSender> HttpTransaction<S> {
response.drain().await?;
// Update the request URL
let previous_url = current_url.clone();
current_url = if location.starts_with("http://") || location.starts_with("https://") {
// Absolute URL
location
@@ -181,6 +182,8 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location)
};
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method
let behavior = if status == 303 {
// 303 See Other always changes to GET
@@ -220,6 +223,33 @@ impl<S: HttpSender> HttpTransaction<S> {
}
}
/// Remove sensitive headers when redirecting to a different host.
/// This matches reqwest's `remove_sensitive_headers()` behavior and prevents
/// credentials from being forwarded to third-party servers (e.g., an
/// Authorization header sent from an API redirect to an S3 bucket).
fn remove_sensitive_headers(
headers: &mut Vec<(String, String)>,
previous_url: &str,
next_url: &str,
) {
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
let next_host = Url::parse(next_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
if previous_host != next_host {
headers.retain(|h| {
let name_lower = h.0.to_lowercase();
name_lower != "authorization"
&& name_lower != "cookie"
&& name_lower != "cookie2"
&& name_lower != "proxy-authorization"
&& name_lower != "www-authenticate"
});
}
}
/// Check if a status code indicates a redirect
fn is_redirect(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
@@ -269,9 +299,20 @@ mod tests {
use tokio::io::AsyncRead;
use tokio::sync::Mutex;
/// Captured request metadata for test assertions
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct CapturedRequest {
url: String,
method: String,
headers: Vec<(String, String)>,
}
/// Mock sender for testing
struct MockSender {
responses: Arc<Mutex<Vec<MockResponse>>>,
/// Captured requests for assertions
captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,
}
struct MockResponse {
@@ -282,7 +323,10 @@ mod tests {
impl MockSender {
fn new(responses: Vec<MockResponse>) -> Self {
Self { responses: Arc::new(Mutex::new(responses)) }
Self {
responses: Arc::new(Mutex::new(responses)),
captured_requests: Arc::new(Mutex::new(Vec::new())),
}
}
}
@@ -290,9 +334,16 @@ mod tests {
impl HttpSender for MockSender {
async fn send(
&self,
_request: SendableHttpRequest,
request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
// Capture the request metadata for later assertions
self.captured_requests.lock().await.push(CapturedRequest {
url: request.url.clone(),
method: request.method.clone(),
headers: request.headers.clone(),
});
let mut responses = self.responses.lock().await;
if responses.is_empty() {
Err(crate::error::Error::RequestError("No more mock responses".to_string()))
@@ -726,4 +777,116 @@ mod tests {
assert!(result.is_ok());
assert_eq!(request_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_cross_origin_redirect_strips_auth_headers() {
// Redirect from api.example.com -> s3.amazonaws.com should strip Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://s3.amazonaws.com/bucket/file.pdf".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()),
("Accept".to_string(), "application/pdf".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// First request should have the Authorization header
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
// Second request (to different host) should NOT have the Authorization header
assert!(
!requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to different host should NOT have Authorization header"
);
// Non-sensitive headers should still be present
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")),
"Non-sensitive headers should be preserved across cross-origin redirects"
);
}
#[tokio::test]
async fn test_same_origin_redirect_preserves_auth_headers() {
// Redirect within the same host should keep Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://api.example.com/v2/download".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/v1/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Bearer token123".to_string()),
("Accept".to_string(), "application/json".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// Both requests should have the Authorization header (same host)
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to same host should preserve Authorization header"
);
}
}

View File

@@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +174,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +482,9 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -587,6 +587,19 @@ pub struct PromptFormRequest {
pub confirm_text: Option<String>,
#[ts(optional)]
pub cancel_text: Option<String>,
#[ts(optional)]
pub size: Option<DialogSize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_events.ts")]
pub enum DialogSize {
Sm,
Md,
Lg,
Full,
Dynamic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -594,6 +607,8 @@ pub struct PromptFormRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct PromptFormResponse {
pub values: Option<HashMap<String, JsonPrimitive>>,
#[ts(optional)]
pub done: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -936,6 +951,22 @@ pub enum EditorLanguage {
Xml,
Graphql,
Markdown,
C,
Clojure,
Csharp,
Go,
Http,
Java,
Kotlin,
ObjectiveC,
Ocaml,
Php,
Powershell,
Python,
R,
Ruby,
Shell,
Swift,
}
impl Default for EditorLanguage {
@@ -966,6 +997,10 @@ pub struct FormInputEditor {
#[ts(optional)]
pub read_only: Option<bool>,
/// Fixed number of visible rows
#[ts(optional)]
pub rows: Option<i32>,
#[ts(optional)]
pub completion_options: Option<Vec<GenericCompletionOption>>,
}

View File

@@ -31,7 +31,7 @@ use std::time::Duration;
use tokio::fs::read_dir;
use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc};
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, timeout};
use yaak_models::models::Plugin;
use yaak_models::util::generate_id;
@@ -43,6 +43,7 @@ pub struct PluginManager {
subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,
plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,
kill_tx: tokio::sync::watch::Sender<bool>,
killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,
ws_service: Arc<PluginRuntimeServerWebsocket>,
vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf,
@@ -70,6 +71,7 @@ impl PluginManager {
) -> PluginManager {
let (events_tx, mut events_rx) = mpsc::channel(2048);
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
let (killed_tx, killed_rx) = oneshot::channel();
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);
@@ -81,6 +83,7 @@ impl PluginManager {
subscribers: Default::default(),
ws_service: Arc::new(ws_service.clone()),
kill_tx: kill_server_tx,
killed_rx: Arc::new(Mutex::new(Some(killed_rx))),
vendored_plugin_dir,
installed_plugin_dir,
dev_mode,
@@ -141,9 +144,15 @@ impl PluginManager {
});
// 2. Start Node.js runtime
start_nodejs_plugin_runtime(&node_bin_path, &plugin_runtime_main, addr, &kill_server_rx)
.await
.unwrap();
start_nodejs_plugin_runtime(
&node_bin_path,
&plugin_runtime_main,
addr,
&kill_server_rx,
killed_tx,
)
.await
.unwrap();
info!("Waiting for plugins to initialize");
init_plugins_task.await.unwrap();
@@ -296,8 +305,15 @@ impl PluginManager {
pub async fn terminate(&self) {
self.kill_tx.send_replace(true);
// Give it a bit of time to kill
tokio::time::sleep(Duration::from_millis(500)).await;
// Wait for the plugin runtime process to actually exit
let killed_rx = self.killed_rx.lock().await.take();
if let Some(rx) = killed_rx {
if timeout(Duration::from_secs(5), rx).await.is_err() {
warn!("Timed out waiting for plugin runtime to exit");
} else {
info!("Plugin runtime exited")
}
}
}
pub async fn reply(

View File

@@ -4,6 +4,7 @@ use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::oneshot;
use tokio::sync::watch::Receiver;
use yaak_common::command::new_xplatform_command;
@@ -19,6 +20,7 @@ pub async fn start_nodejs_plugin_runtime(
plugin_runtime_main: &Path,
addr: SocketAddr,
kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>,
) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str =
@@ -72,6 +74,7 @@ pub async fn start_nodejs_plugin_runtime(
warn!("Failed to kill plugin runtime: {e}");
}
info!("Killed plugin runtime");
let _ = killed_tx.send(());
});
Ok(())

View File

@@ -1,31 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { WebsocketConnection, WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
export function upsertWebsocketRequest(
request: WebsocketRequest | Partial<Omit<WebsocketRequest, 'id'>>,
) {
return invoke('cmd_ws_upsert_request', {
request,
}) as Promise<WebsocketRequest>;
}
export function duplicateWebsocketRequest(requestId: string) {
return invoke('cmd_ws_duplicate_request', {
requestId,
}) as Promise<WebsocketRequest>;
}
export function deleteWebsocketRequest(requestId: string) {
return invoke('cmd_ws_delete_request', {
requestId,
});
}
export function deleteWebsocketConnection(connectionId: string) {
return invoke('cmd_ws_delete_connection', {
connectionId,
});
}
import { WebsocketConnection } from '@yaakapp-internal/models';
export function deleteWebsocketConnections(requestId: string) {
return invoke('cmd_ws_delete_connections', {
@@ -33,20 +7,6 @@ export function deleteWebsocketConnections(requestId: string) {
});
}
export function listWebsocketRequests({ workspaceId }: { workspaceId: string }) {
return invoke('cmd_ws_list_requests', { workspaceId }) as Promise<WebsocketRequest[]>;
}
export function listWebsocketEvents({ connectionId }: { connectionId: string }) {
return invoke('cmd_ws_list_events', { connectionId }) as Promise<WebsocketEvent[]>;
}
export function listWebsocketConnections({ workspaceId }: { workspaceId: string }) {
return invoke('cmd_ws_list_connections', { workspaceId }) as Promise<
WebsocketConnection[]
>;
}
export function connectWebsocket({
requestId,
environmentId,

236
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -62,6 +63,13 @@
"crates/yaak-ws",
"src-web"
],
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
@@ -736,6 +744,19 @@
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
@@ -753,6 +774,16 @@
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
@@ -793,6 +824,32 @@
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
@@ -836,6 +893,15 @@
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
@@ -1414,9 +1480,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz",
"integrity": "sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==",
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1570,6 +1636,17 @@
"lezer-generator": "src/lezer-generator.cjs"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -1590,6 +1667,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
@@ -1631,6 +1719,28 @@
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
@@ -1675,12 +1785,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.7",
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1688,14 +1798,15 @@
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
@@ -2021,6 +2132,19 @@
"node": ">=16.9"
}
},
"node_modules/@readme/httpsnippet": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@readme/httpsnippet/-/httpsnippet-11.0.0.tgz",
"integrity": "sha512-XSyaAsJkZfmMO9R4WDlVJARZgd4wlImftSkMkKclidniXA1h6DTya9iTqJenQo9mHQLh3u6kAC3CDRaIV+LbLw==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.2",
"stringify-object": "^3.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@replit/codemirror-emacs": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
@@ -4044,6 +4168,10 @@
"resolved": "plugins/filter-xpath",
"link": true
},
"node_modules/@yaak/httpsnippet": {
"resolved": "plugins-external/httpsnippet",
"link": true
},
"node_modules/@yaak/importer-curl": {
"resolved": "plugins/importer-curl",
"link": true
@@ -6865,10 +6993,13 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@@ -7407,6 +7538,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
"integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
"license": "ISC"
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -8135,6 +8272,15 @@
"node": ">= 0.4"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ip-bigint": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ip-bigint/-/ip-bigint-7.3.0.tgz",
@@ -8516,6 +8662,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-obj": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -13776,6 +13931,29 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stringify-object": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
"integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
"license": "BSD-2-Clause",
"dependencies": {
"get-own-enumerable-property-symbols": "^3.0.0",
"is-obj": "^1.0.1",
"is-regexp": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/stringify-object/node_modules/is-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
"integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -15775,13 +15953,41 @@
"undici-types": "~7.16.0"
}
},
"plugins-external/httpsnippet": {
"name": "@yaak/httpsnippet",
"version": "1.0.0",
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
},
"plugins-external/httpsnippet/node_modules/@types/node": {
"version": "22.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz",
"integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"plugins-external/httpsnippet/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"plugins-external/mcp-server": {
"name": "@yaak/mcp-server",
"version": "0.1.7",
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.7",
"zod": "^3.25.76"
},

View File

@@ -12,6 +12,7 @@
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins-external/httpsnippet",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
@@ -104,5 +105,12 @@
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2"
}
}

View File

@@ -66,7 +66,9 @@ export type DeleteModelRequest = { model: string, id: string, };
export type DeleteModelResponse = { model: AnyModel, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic";
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift";
export type EmptyPayload = {};
@@ -172,7 +174,11 @@ hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
@@ -476,9 +482,9 @@ label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginContext = { id: string, label: string | null, workspaceId: string | null, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, };
export type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, };
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -1,10 +1,12 @@
import type {
FindHttpResponsesRequest,
FindHttpResponsesResponse,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
JsonPrimitive,
ListCookieNamesResponse,
ListFoldersRequest,
ListFoldersResponse,
@@ -27,6 +29,39 @@ import type {
} from '../bindings/gen_events.ts';
import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
import type { MaybePromise } from '../helpers';
export type CallPromptFormDynamicArgs = {
values: { [key in string]?: JsonPrimitive };
};
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallPromptFormDynamicArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicPromptFormArg = AddDynamic<FormInput>;
type DynamicPromptFormRequest = Omit<PromptFormRequest, 'inputs'> & {
inputs: DynamicPromptFormArg[];
};
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -39,7 +74,7 @@ export interface Context {
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
form(args: PromptFormRequest): Promise<PromptFormResponse['values']>;
form(args: DynamicPromptFormRequest): Promise<PromptFormResponse['values']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;

View File

@@ -2,21 +2,22 @@ import type { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from './Context';
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { TemplateFunctionPlugin };
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { FolderActionPlugin } from './FolderActionPlugin';
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
/**
* The global structure of a Yaak plugin

View File

@@ -1,7 +1,12 @@
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import type { Context, PluginDefinition } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
PluginDefinition,
} from '@yaakapp/api';
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
@@ -12,6 +17,7 @@ import type {
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
@@ -55,6 +61,7 @@ export class PluginInstance {
#mod: PluginDefinition;
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
#pendingDynamicForms = new Map<string, DynamicPromptFormArg[]>();
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
@@ -106,6 +113,7 @@ export class PluginInstance {
async terminate() {
await this.#mod?.dispose?.();
this.#pendingDynamicForms.clear();
this.#unimportModule();
}
@@ -299,7 +307,7 @@ export class PluginInstance {
const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId,
function: { ...fn, args: resolvedArgs },
function: { ...fn, args: stripDynamicCallbacks(resolvedArgs) },
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -326,7 +334,7 @@ export class PluginInstance {
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
args: stripDynamicCallbacks(resolvedArgs),
actions: resolvedActions,
pluginRefId: this.#workerData.pluginRefId,
};
@@ -664,10 +672,66 @@ export class PluginInstance {
return reply.value;
},
form: async (args) => {
const reply: PromptFormResponse = await this.#sendForReply(context, {
type: 'prompt_form_request',
...args,
// Resolve dynamic callbacks on initial inputs using default values
const defaults = applyFormInputDefaults(args.inputs, {});
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
const resolvedInputs = await applyDynamicFormInput(
this.#newCtx(context),
args.inputs,
callArgs,
);
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
// Build the event manually so we can get the event ID for keying
const eventToSend = this.#buildEventToSend(
context,
{ type: 'prompt_form_request', ...args, inputs: strippedInputs },
null,
);
// Store original inputs (with dynamic callbacks) for later resolution
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
const reply = await new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type === 'prompt_form_response') {
const { done, values } = event.payload as PromptFormResponse;
if (done) {
// Final response — resolve the promise and clean up
this.#appToPluginEvents.unlisten(cb);
this.#pendingDynamicForms.delete(eventToSend.id);
resolve({ values } as PromptFormResponse);
} else {
// Intermediate value change — resolve dynamic inputs and send back
// Skip empty values (fired on initial mount before user interaction)
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
if (storedInputs && values && Object.keys(values).length > 0) {
const ctx = this.#newCtx(context);
const callArgs: CallPromptFormDynamicArgs = { values };
applyDynamicFormInput(ctx, storedInputs, callArgs)
.then((resolvedInputs) => {
const stripped = stripDynamicCallbacks(resolvedInputs);
this.#sendPayload(
context,
{ type: 'prompt_form_request', ...args, inputs: stripped },
eventToSend.id,
);
})
.catch((err) => {
console.error('Failed to resolve dynamic form inputs', err);
});
}
}
}
};
this.#appToPluginEvents.listen(cb);
// Send the initial event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
});
return reply.values;
},
},
@@ -788,12 +852,12 @@ export class PluginInstance {
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async (args) => {
create: async ({ name, ...args }) => {
const payload = {
type: 'upsert_model_request',
model: {
name: '',
...args,
name: name ?? '',
id: '',
model: 'folder',
},
@@ -906,6 +970,17 @@ export class PluginInstance {
}
}
function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => {
// biome-ignore lint/suspicious/noExplicitAny: stripping dynamic from union type
const { dynamic, ...rest } = input as any;
if ('inputs' in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';

View File

@@ -1,9 +1,21 @@
import type { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type {
CallPromptFormDynamicArgs,
Context,
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
} from '@yaakapp/api';
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
} from '@yaakapp-internal/plugins';
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
type AnyCallArgs =
| CallTemplateFunctionArgs
| CallHttpAuthenticationActionArgs
| CallPromptFormDynamicArgs;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
@@ -18,30 +30,40 @@ export async function applyDynamicFormInput(
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[] = [];
args: DynamicPromptFormArg[],
callArgs: CallPromptFormDynamicArgs,
): Promise<DynamicPromptFormArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: AnyDynamicArg[],
callArgs: AnyCallArgs,
): Promise<AnyDynamicArg[]> {
const resolvedArgs: AnyDynamicArg[] = [];
for (const { dynamic, ...arg } of args) {
const dynamicResult =
typeof dynamic === 'function'
? await dynamic(
ctx,
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
)
: undefined;
const newArg = {
...arg,
...dynamicResult,
} as DynamicTemplateFunctionArg | DynamicAuthenticationArg;
} as AnyDynamicArg;
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
try {
newArg.inputs = await applyDynamicFormInput(
ctx,
newArg.inputs as DynamicTemplateFunctionArg[],
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
callArgs as CallTemplateFunctionArgs &
CallHttpAuthenticationActionArgs &
CallPromptFormDynamicArgs,
);
} catch (e) {
console.error('Failed to apply dynamic form input', e);

View File

@@ -0,0 +1,45 @@
# Yaak HTTP Snippet Plugin
Generate code snippets for HTTP requests in various languages and frameworks,
powered by [@readme/httpsnippet](https://github.com/readmeio/httpsnippet).
![Httpsnippet plugin](https://assets.yaak.app/uploads/httpsnippet-guiaX_1786x1420.png)
## How It Works
Right-click any HTTP request (or use the `...` menu) and select **Generate Code Snippet**.
A dialog lets you pick a language and library, with a live preview of the generated code.
Click **Copy to Clipboard** to copy the snippet. Your language and library selections are
remembered for next time.
## Supported Languages
Each language supports one or more libraries:
| Language | Libraries |
|---|---|
| C | libcurl |
| Clojure | clj-http |
| C# | HttpClient, RestSharp |
| Go | Native |
| HTTP | HTTP/1.1 |
| Java | AsyncHttp, NetHttp, OkHttp, Unirest |
| JavaScript | Axios, fetch, jQuery, XHR |
| Kotlin | OkHttp |
| Node.js | Axios, fetch, HTTP, Request, Unirest |
| Objective-C | NSURLSession |
| OCaml | CoHTTP |
| PHP | cURL, Guzzle, HTTP v1, HTTP v2 |
| PowerShell | Invoke-WebRequest, RestMethod |
| Python | http.client, Requests |
| R | httr |
| Ruby | Native |
| Shell | cURL, HTTPie, Wget |
| Swift | URLSession |
## Features
- Renders template variables before generating snippets, so the output reflects real values
- Supports all body types: JSON, form-urlencoded, multipart, GraphQL, and raw text
- Includes authentication headers (Basic, Bearer, and API Key)
- Includes query parameters and custom headers

View File

@@ -0,0 +1,24 @@
{
"name": "@yaak/httpsnippet",
"private": true,
"version": "1.0.3",
"displayName": "HTTP Snippet",
"description": "Generate code snippets for HTTP requests in various languages and frameworks",
"minYaakVersion": "2026.2.0-beta.10",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/httpsnippet"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@readme/httpsnippet": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,314 @@
import { availableTargets, type HarRequest, HTTPSnippet } from '@readme/httpsnippet';
import type { EditorLanguage, HttpRequest, PluginDefinition } from '@yaakapp/api';
// Get all available targets and build select options
const targets = availableTargets();
// Targets to exclude from the language list
const excludedTargets = new Set(['json']);
// Build language (target) options
const languageOptions = targets
.filter((target) => !excludedTargets.has(target.key))
.map((target) => ({
label: target.title,
value: target.key,
}));
// Preferred clients per target (shown first in the list)
const preferredClients: Record<string, string> = {
javascript: 'fetch',
node: 'fetch',
};
// Get client options for a given target key
function getClientOptions(targetKey: string) {
const target = targets.find((t) => t.key === targetKey);
if (!target) return [];
const preferred = preferredClients[targetKey];
return target.clients
.map((client) => ({
label: client.title,
value: client.key,
}))
.sort((a, b) => {
if (a.value === preferred) return -1;
if (b.value === preferred) return 1;
return 0;
});
}
// Get default client for a target
function getDefaultClient(targetKey: string): string {
const options = getClientOptions(targetKey);
return options[0]?.value ?? '';
}
// Defaults
const defaultTarget = 'javascript';
// Map httpsnippet target key to editor language for syntax highlighting
const editorLanguageMap: Record<string, EditorLanguage> = {
c: 'c',
clojure: 'clojure',
csharp: 'csharp',
go: 'go',
http: 'http',
java: 'java',
javascript: 'javascript',
kotlin: 'kotlin',
node: 'javascript',
objc: 'objective_c',
ocaml: 'ocaml',
php: 'php',
powershell: 'powershell',
python: 'python',
r: 'r',
ruby: 'ruby',
shell: 'shell',
swift: 'swift',
};
function getEditorLanguage(targetKey: string): EditorLanguage {
return editorLanguageMap[targetKey] ?? 'text';
}
// Convert Yaak HttpRequest to HAR format
function toHarRequest(request: Partial<HttpRequest>) {
// Build URL with query parameters
let finalUrl = request.url || '';
const urlParams = (request.urlParameters ?? []).filter((p) => p.enabled !== false && !!p.name);
if (urlParams.length > 0) {
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Build headers array
const headers: Array<{ name: string; value: string }> = (request.headers ?? [])
.filter((h) => h.enabled !== false && !!h.name)
.map((h) => ({ name: h.name, value: h.value }));
// Handle authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const credentials = btoa(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
);
headers.push({ name: 'Authorization', value: `Basic ${credentials}` });
} else if (request.authenticationType === 'bearer') {
const prefix = request.authentication?.prefix ?? 'Bearer';
const token = request.authentication?.token ?? '';
headers.push({ name: 'Authorization', value: `${prefix} ${token}`.trim() });
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'header') {
headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
} else if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
}
}
}
// Build HAR request object
const har: Record<string, unknown> = {
method: request.method || 'GET',
url: finalUrl,
headers,
};
// Handle request body
const bodyType = request.bodyType ?? 'none';
if (bodyType !== 'none' && request.body) {
if (bodyType === 'application/x-www-form-urlencoded' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string }) => ({ name: p.name, value: p.value }));
har.postData = {
mimeType: 'application/x-www-form-urlencoded',
params,
};
} else if (bodyType === 'multipart/form-data' && Array.isArray(request.body.form)) {
const params = request.body.form
.filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)
.map((p: { name: string; value: string; file?: string; contentType?: string }) => {
const param: Record<string, string> = { name: p.name, value: p.value || '' };
if (p.file) param.fileName = p.file;
if (p.contentType) param.contentType = p.contentType;
return param;
});
har.postData = {
mimeType: 'multipart/form-data',
params,
};
} else if (bodyType === 'graphql' && typeof request.body.query === 'string') {
const body = {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
har.postData = {
mimeType: 'application/json',
text: JSON.stringify(body),
};
} else if (typeof request.body.text === 'string') {
har.postData = {
mimeType: bodyType,
text: request.body.text,
};
}
}
return har;
}
function maybeParseJSON<T>(v: unknown, fallback: T): T | unknown {
if (typeof v !== 'string') return fallback;
try {
return JSON.parse(v);
} catch {
return fallback;
}
}
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Generate Code Snippet',
icon: 'copy',
async onSelect(ctx, args) {
// Render the request with variables resolved
const renderedRequest = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'send',
});
// Convert to HAR format
const harRequest = toHarRequest(renderedRequest) as HarRequest;
// Get previously selected language or use defaults
const storedTarget = await ctx.store.get<string>('selectedTarget');
const initialTarget = storedTarget || defaultTarget;
const storedClient = await ctx.store.get<string>(`selectedClient:${initialTarget}`);
const initialClient = storedClient || getDefaultClient(initialTarget);
// Create snippet generator
const snippet = new HTTPSnippet(harRequest);
const generateSnippet = (target: string, client: string): string => {
const result = snippet.convert(target as any, client);
return (Array.isArray(result) ? result.join('\n') : result || '').replace(/\r\n/g, '\n');
};
// Generate initial code preview
let initialCode = '';
try {
initialCode = generateSnippet(initialTarget, initialClient);
} catch {
initialCode = '// Error generating snippet';
}
// Show dialog with language/library selectors and code preview
const result = await ctx.prompt.form({
id: 'httpsnippet',
title: 'Generate Code Snippet',
confirmText: 'Copy to Clipboard',
cancelText: 'Cancel',
size: 'md',
inputs: [
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'target',
label: 'Language',
defaultValue: initialTarget,
options: languageOptions,
},
{
type: 'select',
name: `client-${initialTarget}`,
label: 'Library',
defaultValue: initialClient,
options: getClientOptions(initialTarget),
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const options = getClientOptions(targetKey);
return {
name: `client-${targetKey}`,
options,
defaultValue: options[0]?.value ?? '',
};
},
},
],
},
{
type: 'editor',
name: 'code',
label: 'Preview',
language: getEditorLanguage(initialTarget),
defaultValue: initialCode,
readOnly: true,
rows: 15,
dynamic(_ctx, { values }) {
const targetKey = String(values.target || defaultTarget);
const clientKey = String(
values[`client-${targetKey}`] || getDefaultClient(targetKey),
);
let code: string;
try {
code = generateSnippet(targetKey, clientKey);
} catch {
code = '// Error generating snippet';
}
return {
defaultValue: code,
language: getEditorLanguage(targetKey),
};
},
},
],
});
if (result) {
// Store the selected language and library for next time
const selectedTarget = String(result.target || initialTarget);
const selectedClient = String(
result[`client-${selectedTarget}`] || getDefaultClient(selectedTarget),
);
await ctx.store.set('selectedTarget', selectedTarget);
await ctx.store.set(`selectedClient:${selectedTarget}`, selectedClient);
// Generate snippet for the selected language
try {
const codeText = generateSnippet(selectedTarget, selectedClient);
await ctx.clipboard.copyText(codeText);
await ctx.toast.show({
message: 'Code snippet copied to clipboard',
icon: 'copy',
color: 'success',
});
} catch (err) {
await ctx.toast.show({
message: `Failed to generate snippet: ${err}`,
icon: 'alert_triangle',
color: 'danger',
});
}
}
},
},
],
};

View File

@@ -1,10 +1,10 @@
{
"name": "@yaak/mcp-server",
"private": true,
"version": "0.1.7",
"version": "0.2.1",
"displayName": "MCP Server",
"description": "Expose Yaak functionality via Model Context Protocol",
"minYaakVersion": "2025.10.0-beta.6",
"minYaakVersion": "2026.1.0",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
@@ -17,7 +17,7 @@
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.7",
"zod": "^3.25.76"
},

View File

@@ -10,7 +10,7 @@ export const plugin: PluginDefinition = {
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'preview',
purpose: 'send',
});
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);

View File

@@ -184,6 +184,18 @@ export function buildHostedCallbackRedirectUri(localPort: number, localPath: str
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
}
/**
* Stop the active callback server if one is running.
* Called during plugin dispose to ensure the server is cleaned up before the process exits.
*/
export function stopActiveServer(): void {
if (activeServer) {
console.log('[oauth2] Stopping active callback server during dispose');
activeServer.stop();
activeServer = null;
}
}
/**
* Open an authorization URL in the system browser, start a local callback server,
* and wait for the OAuth provider to redirect back.

View File

@@ -5,7 +5,7 @@ import type {
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
@@ -78,6 +78,9 @@ const accessTokenUrls = [
];
export const plugin: PluginDefinition = {
dispose() {
stopActiveServer();
},
authentication: {
name: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -55,6 +55,7 @@ const requestArg: FormInput = {
type: 'http_request',
name: 'request',
label: 'Request',
defaultValue: '', // Make it not select the active one by default
};
export const plugin: PluginDefinition = {

View File

@@ -1,21 +1,14 @@
import { getModel } from '@yaakapp-internal/models';
import { Icon } from '../components/core/Icon';
import { HStack } from '../components/core/Stacks';
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
const folder = getModel('folder', folderId);
if (folder == null) return;
showDialog({
id: 'folder-settings',
title: (
<HStack space={2} alignItems="center">
<Icon icon="folder_cog" size="xl" color="secondary" />
{resolvedModelName(folder)}
</HStack>
),
title: null,
size: 'lg',
className: 'h-[50rem]',
noPadding: true,

View File

@@ -317,7 +317,7 @@ function TextArg({
autocompleteFunctions,
autocompleteVariables,
};
if (autocompleteVariables || autocompleteFunctions) {
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
return <Input {...props} />;
}
return <PlainInput {...props} />;
@@ -360,8 +360,9 @@ function EditorArg({
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
'max-h-[10rem]', // So it doesn't take up too much space
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
<Editor
id={id}

View File

@@ -1,15 +1,25 @@
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
import {
createWorkspaceModel,
foldersAtom,
patchModel,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { Fragment, useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { hideDialog } from '../lib/dialog';
import { CopyIconButton } from './CopyIconButton';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Link } from './core/Link';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -37,6 +47,8 @@ export type FolderSettingsTab =
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
@@ -67,76 +79,132 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
if (folder == null) return null;
return (
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<VStack space={3} className="pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
</VStack>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon
icon="chevron_right"
size="lg"
className="opacity-50 flex-shrink-0"
/>
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
</span>
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)}
<span
className="whitespace-nowrap"
title={folder.name}
>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog('folder-settings');
}
}}
color="danger"
variant="border"
size="xs"
>
Create Folder Environment
Delete Folder
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -239,7 +239,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer

View File

@@ -135,6 +135,7 @@ export function Workspace() {
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}

View File

@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { DialogSize } from '@yaakapp-internal/plugins';
export interface DialogProps {
children: ReactNode;
@@ -14,7 +15,7 @@ export interface DialogProps {
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg' | 'full' | 'dynamic';
size?: DialogSize;
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;

View File

@@ -5,11 +5,14 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
bracketMatching,
codeFolding,
@@ -17,8 +20,19 @@ import {
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import { c, csharp, kotlin, objectiveC } from '@codemirror/legacy-modes/mode/clike';
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
import { http } from '@codemirror/legacy-modes/mode/http';
import { oCaml } from '@codemirror/legacy-modes/mode/mllike';
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
import { r } from '@codemirror/legacy-modes/mode/r';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
@@ -83,6 +97,10 @@ const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
null | (() => LanguageSupport)
@@ -98,6 +116,22 @@ const syntaxExtensions: Record<
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
http: legacyLang(http),
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];

View File

@@ -111,6 +111,7 @@ import {
RefreshCcwIcon,
RefreshCwIcon,
RocketIcon,
RotateCcwIcon,
Rows2Icon,
SaveIcon,
SearchIcon,
@@ -249,6 +250,7 @@ const icons = {
puzzle: PuzzleIcon,
refresh: RefreshCwIcon,
rocket: RocketIcon,
rotate_ccw: RotateCcwIcon,
rows_2: Rows2Icon,
save: SaveIcon,
search: SearchIcon,

View File

@@ -1,6 +1,6 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { DynamicForm } from '../DynamicForm';
import { Button } from './Button';
@@ -12,16 +12,21 @@ export interface PromptProps {
onResult: (value: Record<string, JsonPrimitive> | null) => void;
confirmText?: string;
cancelText?: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
}
export function Prompt({
onCancel,
inputs,
inputs: initialInputs,
onResult,
confirmText = 'Confirm',
cancelText = 'Cancel',
onValuesChange,
onInputsUpdated,
}: PromptProps) {
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
const [inputs, setInputs] = useState<FormInput[]>(initialInputs);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -30,6 +35,16 @@ export function Prompt({
[onResult, value],
);
// Register callback for external input updates (from plugin dynamic resolution)
useEffect(() => {
onInputsUpdated?.(setInputs);
}, [onInputsUpdated]);
// Notify of value changes for dynamic resolution
useEffect(() => {
onValuesChange?.(value);
}, [value, onValuesChange]);
const id = `prompt.form.${useRef(generateId()).current}`;
return (

View File

@@ -0,0 +1,66 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export interface RadioCardOption<T extends string> {
value: T;
label: ReactNode;
description?: ReactNode;
}
export interface RadioCardsProps<T extends string> {
value: T | null;
onChange: (value: T) => void;
options: RadioCardOption<T>[];
name: string;
}
export function RadioCards<T extends string>({
value,
onChange,
options,
name,
}: RadioCardsProps<T>) {
return (
<div className="flex flex-col gap-2">
{options.map((option) => {
const selected = value === option.value;
return (
<label
key={option.value}
className={classNames(
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
'transition-colors',
selected
? 'border-border-focus'
: 'border-border-subtle hocus:border-text-subtlest',
)}
>
<input
type="radio"
name={name}
value={option.value}
checked={selected}
onChange={() => onChange(option.value)}
className="sr-only"
/>
<div
className={classNames(
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
'flex items-center justify-center',
selected ? 'border-focus' : 'border-border',
)}
>
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-text">{option.label}</span>
{option.description && (
<span className="text-sm text-text-subtle">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
);
}

View File

@@ -20,7 +20,7 @@ import { InlineCode } from '../core/InlineCode';
import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog';
import { handlePullResult } from './git-util';
import { handlePullResult, handlePushResult } from './git-util';
import { HistoryDialog } from './HistoryDialog';
export function GitDropdown() {
@@ -48,6 +48,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
push,
pull,
checkout,
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
@@ -72,6 +73,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}
const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
const hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data;
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
@@ -168,12 +172,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{ type: 'separator' },
{
label: 'Push',
disabled: !hasRemotes || ahead === 0,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: 'git-push-error',
@@ -186,7 +191,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Pull',
hidden: (status.data?.origins ?? []).length === 0,
disabled: !hasRemotes || behind === 0,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -205,6 +210,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Commit...',
disabled: !hasChanges,
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -218,6 +224,41 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
});
},
},
{
label: 'Reset Changes',
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: 'danger',
async onSelect() {
const confirmed = await showConfirm({
id: 'git-reset-changes',
title: 'Reset Changes',
description: 'This will discard all uncommitted changes. This cannot be undone.',
confirmText: 'Reset',
color: 'danger',
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: 'git-reset-success',
message: 'Changes have been reset',
color: 'success',
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-reset-error',
title: 'Error resetting changes',
message: String(err),
});
},
});
},
},
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
@@ -463,8 +504,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return (
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
<GitMenuButton>
<InlineCode>{currentBranch}</InlineCode>
<Icon icon="git_branch" size="sm" />
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary"></span>{ahead}</span>}
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info"></span>{behind}</span>}
</div>
</GitMenuButton>
</Dropdown>
);

View File

@@ -1,5 +1,8 @@
import type { GitCallbacks } from '@yaakapp-internal/git';
import { sync } from '../../init/sync';
import { promptCredentials } from './credentials';
import { promptDivergedStrategy } from './diverged';
import { promptUncommittedChangesStrategy } from './uncommitted';
import { addGitRemote } from './showAddRemoteDialog';
export function gitCallbacks(dir: string): GitCallbacks {
@@ -12,5 +15,12 @@ export function gitCallbacks(dir: string): GitCallbacks {
if (creds == null) throw new Error('Cancelled credentials prompt');
return creds;
},
promptDiverged: async ({ remote, branch }) => {
return promptDivergedStrategy({ remote, branch });
},
promptUncommittedChanges: async () => {
return promptUncommittedChangesStrategy();
},
forceSync: () => sync({ force: true }),
};
}

View File

@@ -0,0 +1,102 @@
import type { DivergedStrategy } from '@yaakapp-internal/git';
import { useState } from 'react';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
import { RadioCards } from '../core/RadioCards';
import { HStack } from '../core/Stacks';
type Resolution = 'force_reset' | 'merge';
const resolutionLabel: Record<Resolution, string> = {
force_reset: 'Force Pull',
merge: 'Merge',
};
interface DivergedDialogProps {
remote: string;
branch: string;
onResult: (strategy: DivergedStrategy) => void;
onHide: () => void;
}
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
const [selected, setSelected] = useState<Resolution | null>(null);
const handleSubmit = () => {
if (selected == null) return;
onResult(selected);
onHide();
};
const handleCancel = () => {
onResult('cancel');
onHide();
};
return (
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{' '}
<InlineCode>
{remote}/{branch}
</InlineCode>. How would you like to resolve this?
</p>
<RadioCards
name="diverged-strategy"
value={selected}
onChange={setSelected}
options={[
{
value: 'merge',
label: 'Merge Commit',
description: 'Combining local and remote changes into a single merge commit',
},
{
value: 'force_reset',
label: 'Force Pull',
description: 'Discard local commits and reset to match the remote branch',
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === 'force_reset' ? 'danger' : 'primary'}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : 'Select an option'}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
export async function promptDivergedStrategy({
remote,
branch,
}: {
remote: string;
branch: string;
}): Promise<DivergedStrategy> {
return new Promise((resolve) => {
showDialog({
id: 'git-diverged',
title: 'Branches Diverged',
hideX: true,
size: 'sm',
disableBackdropClose: true,
onClose: () => resolve('cancel'),
render: ({ hide }) =>
DivergedDialog({
remote,
branch,
onHide: hide,
onResult: resolve,
}),
});
});
}

View File

@@ -26,5 +26,11 @@ export function handlePullResult(r: PullResult) {
case 'up_to_date':
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
break;
case 'diverged':
// Handled by mutation callback before reaching here
break;
case 'uncommitted_changes':
// Handled by mutation callback before reaching here
break;
}
}

View File

@@ -0,0 +1,13 @@
import type { UncommittedChangesStrategy } from '@yaakapp-internal/git';
import { showConfirm } from '../../lib/confirm';
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
const confirmed = await showConfirm({
id: 'git-uncommitted-changes',
title: 'Uncommitted Changes',
description: 'You have uncommitted changes. Commit or reset your changes before pulling.',
confirmText: 'Reset and Pull',
color: 'danger',
});
return confirmed ? 'reset' : 'cancel';
}

View File

@@ -1,6 +1,12 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import { debounce } from '@yaakapp-internal/lib';
import type {
FormInput,
InternalEvent,
JsonPrimitive,
ShowToastRequest,
} from '@yaakapp-internal/plugins';
import { updateAllPlugins } from '@yaakapp-internal/plugins';
import type {
PluginUpdateNotification,
@@ -32,6 +38,9 @@ export function initGlobalListeners() {
listenToTauriEvent('settings', () => openSettings.mutate(null));
// Track active dynamic form dialogs so follow-up input updates can reach them
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
// Listen for plugin events
listenToTauriEvent<InternalEvent>('plugin_event', async ({ payload: event }) => {
if (event.payload.type === 'prompt_text_request') {
@@ -49,26 +58,47 @@ export function initGlobalListeners() {
};
await emit(event.id, result);
} else if (event.payload.type === 'prompt_form_request') {
if (event.replyId != null) {
// Follow-up update from plugin runtime — update the active dialog's inputs
const updateInputs = activeForms.get(event.replyId);
if (updateInputs) {
updateInputs(event.payload.inputs);
}
return;
}
// Initial request — show the dialog with bidirectional support
const emitFormResponse = (values: Record<string, JsonPrimitive> | null, done: boolean) => {
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
done,
},
};
emit(event.id, result);
};
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
size: event.payload.size,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
onValuesChange: debounce((values) => emitFormResponse(values, false), 150),
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
});
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: 'prompt_form_response',
values,
},
};
await emit(event.id, result);
// Clean up and send final response
activeForms.delete(event.id);
emitFormResponse(values, true);
}
});

View File

@@ -1,21 +1,32 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { DialogProps } from '../components/core/Dialog';
import type { PromptProps } from '../components/core/Prompt';
import { Prompt } from '../components/core/Prompt';
import { showDialog } from './dialog';
type FormArgs = Pick<DialogProps, 'title' | 'description'> &
type FormArgs = Pick<DialogProps, 'title' | 'description' | 'size'> &
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & {
id: string;
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
};
export async function showPromptForm({ id, title, description, ...props }: FormArgs) {
export async function showPromptForm({
id,
title,
description,
size,
onValuesChange,
onInputsUpdated,
...props
}: FormArgs) {
return new Promise((resolve: PromptProps['onResult']) => {
showDialog({
id,
title,
description,
hideX: true,
size: 'sm',
size: size ?? 'sm',
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
@@ -32,6 +43,8 @@ export async function showPromptForm({ id, title, description, ...props }: FormA
resolve(v);
hide();
},
onValuesChange,
onInputsUpdated,
...props,
}),
});

View File

@@ -9,7 +9,6 @@ type TauriCmd =
| 'cmd_call_workspace_action'
| 'cmd_call_folder_action'
| 'cmd_check_for_updates'
| 'cmd_create_grpc_request'
| 'cmd_curl_to_request'
| 'cmd_decrypt_template'
| 'cmd_default_headers'
@@ -48,9 +47,7 @@ type TauriCmd =
| 'cmd_save_response'
| 'cmd_secure_template'
| 'cmd_send_ephemeral_request'
| 'cmd_send_folder'
| 'cmd_send_http_request'
| 'cmd_show_workspace_key'
| 'cmd_template_function_summaries'
| 'cmd_template_function_config'
| 'cmd_template_tokens_to_string';