From 314f08545c50dd5f1ee12659caf000d2d15d64f5 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 13 Aug 2026 19:26:44 -0700 Subject: [PATCH] Add a Yaak CLI skill for coding agents, installed by `yaak agent install` Teaches agents to drive the CLI: workspaces, environments, requests, sending, response chaining, and importing. SKILL.md stays lean with four references loaded on demand. The skill is embedded in the binary and written to ~/.agents/skills plus any detected tool directory, so it ships with the CLI and refreshes on update. Reinstalls keep files edited locally unless --force. Also fixes the `request schema http` hint for URL path parameters, which said to omit the leading colon. Names without the colon are sent as query string parameters instead, leaving the placeholder literal in the path. --- crates-cli/yaak-cli/skills/use-yaak/SKILL.md | 239 ++++++++++++++++++ .../skills/use-yaak/references/chaining.md | 115 +++++++++ .../use-yaak/references/environments.md | 122 +++++++++ .../use-yaak/references/import-export.md | 71 ++++++ .../skills/use-yaak/references/requests.md | 195 ++++++++++++++ crates-cli/yaak-cli/src/cli.rs | 33 +++ crates-cli/yaak-cli/src/commands/agent.rs | 204 +++++++++++++++ crates-cli/yaak-cli/src/commands/mod.rs | 1 + crates-cli/yaak-cli/src/commands/request.rs | 2 +- crates-cli/yaak-cli/src/main.rs | 1 + 10 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 crates-cli/yaak-cli/skills/use-yaak/SKILL.md create mode 100644 crates-cli/yaak-cli/skills/use-yaak/references/chaining.md create mode 100644 crates-cli/yaak-cli/skills/use-yaak/references/environments.md create mode 100644 crates-cli/yaak-cli/skills/use-yaak/references/import-export.md create mode 100644 crates-cli/yaak-cli/skills/use-yaak/references/requests.md create mode 100644 crates-cli/yaak-cli/src/commands/agent.rs diff --git a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md new file mode 100644 index 00000000..b26b7b54 --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md @@ -0,0 +1,239 @@ +--- +name: use-yaak +description: > + Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces, + folders, environments and variables, author HTTP requests, send them + individually or a whole folder/workspace at once, chain one request's response + into the next, and import existing APIs from OpenAPI, Postman, Insomnia, or + cURL. Use this skill whenever the user mentions Yaak, a Yaak workspace, or the + `yaak` command, and also when they ask to try, hit, call, exercise, or smoke + test an HTTP or REST endpoint, to save or organize API requests for reuse, to + set up API requests for manual testing, to turn an OpenAPI or Postman + collection into runnable requests, or to run a saved request suite against + staging versus production. Prefer this over one-off `curl` commands whenever + the requests should be saved, reused, shared, or run as a set. +allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*) +--- + +# Use Yaak + +Yaak is a desktop API client. The `yaak` CLI reads and writes the **same local +database as the desktop app**, so anything created here shows up in the app +immediately, and vice versa. There is no server and no sign-in: `yaak auth` is +only for publishing plugins to the Yaak registry, not for any workflow below. + +## Resource model + +- **Workspace** (`wk_…`) is the top-level container. It owns everything else. +- **Folder** (`fl_…`) groups requests inside a workspace and can nest. Folders + carry headers and authentication that child requests inherit. +- **Request** (`rq_…`) is a single HTTP, gRPC, or WebSocket request. +- **Environment** (`ev_…`) holds variables. Every workspace has one base + environment ("Global Variables") plus any number of sub-environments; a + sub-environment overrides base variables of the same name. +- **Cookie jar** (`cj_…`) stores cookies per workspace. The oldest jar is used + by default; no setup needed. + +IDs are stable and prefix-typed, so you can always tell what an ID refers to. +Most commands take a workspace ID positionally and **infer it when the machine +has exactly one workspace**. Pass it explicitly once a second workspace exists. + +## Preflight + +```bash +yaak --version || npm install -g @yaakapp/cli +yaak workspace list +``` + +`workspace list` prints `wk_… - Name` per line, or `No workspaces found`. Pick +the workspace that matches the user's project before mutating anything; create +one only when nothing fits. + +If a subcommand documented here is not recognized, the CLI is older than this +skill. Update it, then refresh the skill so the two stay in lockstep: + +```bash +npm install -g @yaakapp/cli@latest && yaak agent install +``` + +Mention to the user that they need to restart their coding tool for the +refreshed skill to load; the current session keeps using the old copy. + +## Command map + +| Goal | Command | +|---|---| +| List | `yaak {workspace,folder,request,environment,cookie-jar} list` | +| Inspect one | `yaak {workspace,folder,request,environment} show ` | +| Create | `yaak {workspace,folder,request,environment} create` | +| Update | `yaak {workspace,folder,request,environment} update --json '{"id":"…",…}'` | +| Delete | `yaak … delete --yes` | +| Inspect the model | `yaak request schema http --pretty`, `yaak workspace schema`, `yaak environment schema` | +| Send one request | `yaak request send ` | +| Send a folder or whole workspace | `yaak send ` | +| Import an existing API | `yaak import ` | +| Export | `yaak export [workspace_id…]` | + +Global flags go anywhere but are clearest before the subcommand: +`-e/--environment `, `--cookie-jar `, `-v/--verbose`, +`--data-dir ` (point at an isolated database, useful for scratch work). + +## Creating and updating + +Simple requests take flags: + +```bash +yaak request create wk_abc123 --name "List Pets" --method GET --url "https://api.example.com/pets" +``` + +Anything richer than name/method/URL takes a JSON payload, either positionally +or via `--json`. **Read the schema before writing a payload you are unsure +of** — it is generated from the real model and includes the plugin-provided +authentication variants: + +```bash +yaak request schema http --pretty +``` + +Rules that are easy to get wrong: + +- **Setting `bodyType` does not add a `Content-Type` header.** The app adds one + when you pick a body type in the UI, but creating a request from the CLI skips + that step, and the body goes out untyped. Add the header yourself, using the + same value as `bodyType` (with `other` → `text/plain` and `graphql` → + `application/json`). Multipart is the exception: leave it alone, the sender + supplies the boundary. +- **Path parameters must keep the leading colon.** For `/pets/:petId`, the + `urlParameters` entry is named `:petId`, not `petId`. Get it wrong and the + placeholder stays literal in the path while the value is appended to the query + string, which usually 404s with no error. +- **Create** payloads must omit `id` (or set it to `""`). +- **Update** payloads must include `id`, and are applied as a JSON merge patch: + keys you omit are left alone, and a key set to `null` is deleted. There is no + need to send the whole object. +- Flags and JSON cannot be combined on the same command. +- `request create` and `request list` are HTTP-only. gRPC and WebSocket requests + exist in the model and can be sent from the app, but the CLI cannot yet create + or send them. + +The first two fail silently, so verify a new request with `yaak -v request send +` and check the `> ` lines actually show the path and headers you intended. + +```bash +yaak request create wk_abc123 --json '{ + "name": "Create Pet", "method": "POST", "url": "${[ base_url ]}/pets", + "bodyType": "application/json", + "body": {"text": "{\"name\":\"Rex\"}"}, + "headers": [{"name": "Content-Type", "value": "application/json", "enabled": true}] +}' +``` + +See [requests.md](references/requests.md) for bodies, headers, authentication, +path parameters, and folder inheritance. + +## Template variables + +Yaak's template syntax is `${[ … ]}`, **not** `{{ … }}`. It works in URLs, +headers, bodies, and auth fields: + +``` +${[ base_url ]}/pets/${[ pet_id ]} +${[ response.body.path(request='rq_abc123', path='$.token') ]} +``` + +Referencing a variable that no active environment defines is a hard error and +the request is not sent, so an unresolved variable can never silently reach the +network. See [environments.md](references/environments.md) for variable scoping +and [chaining.md](references/chaining.md) for pulling values out of earlier +responses. + +## Sending + +```bash +yaak request send rq_abc123 # body only, on stdout +yaak -e ev_staging request send rq_abc123 # against a sub-environment +yaak -v request send rq_abc123 # request/response metadata too +yaak send fl_abc123 --fail-fast # every request in a folder +yaak send wk_abc123 --parallel # every request in a workspace +``` + +**Reading the result.** A plain send writes only the response body to stdout, +with no trailing newline. Like `curl`, the exit code reflects whether the +request completed, not the HTTP status — a 404 or 500 exits 0. Use `-v` when the +status matters: + +```bash +yaak -v request send rq_abc123 2>&1 | grep '^< HTTP' +``` + +Under `-v`, connection/request/response lines (`*`, `>`, `<`) and the body all +go to stdout, with the body following the last `<` header line. Grep for the +prefixes you need rather than assuming a clean split. + +Exit code 1 means the send itself failed: an unresolved template variable, an +unreachable host, a TLS failure. For folders and workspaces the last line is +`Send summary: N succeeded, M failed`, per-request errors follow on stderr, and +the exit code is 1 if any request failed. + +## Running a suite in CI + +Workspace and request IDs survive an export/import, so a committed export gives +a stable, runnable suite on a machine that has never seen the app: + +```bash +npm install -g @yaakapp/cli +yaak --data-dir ./.yaak import ./api-export.json +yaak --data-dir ./.yaak -e ev_ci send wk_abc123 --fail-fast +``` + +`--data-dir` keeps the run isolated from any real Yaak install, and the IDs in +the export are the same ones you used locally. Produce the export with +`yaak export ./api-export.json wk_abc123`, adding +`--include-private-environments` only if the suite needs values you are willing +to commit — otherwise keep secrets in a CI-only environment and inject them. + +**The caveat that matters here:** a failing *assertion* is not a concept Yaak +has, and HTTP error statuses do not fail the run. A workspace of requests that +all return 500 exits 0. The exit code catches unreachable hosts, TLS failures, +and unresolved variables only. To gate CI on status codes, run with `-v` and +check the `< HTTP` lines yourself. Say this plainly rather than implying a green +run means the API is healthy. + +## Importing + +`yaak import` auto-detects OpenAPI/Swagger, Postman, Insomnia, cURL, and Yaak +exports, and creates a new workspace by default: + +```bash +yaak import ./openapi.yaml +yaak import ./collection.json --workspace-id wk_abc123 # merge into an existing one +``` + +This is almost always faster than authoring requests by hand when a spec exists. +See [import-export.md](references/import-export.md). + +## Routing + +| Task | Reference | +|---|---| +| Request bodies, headers, auth, path/query params, folder inheritance | [requests.md](references/requests.md) | +| Environment hierarchy, variables, per-environment runs | [environments.md](references/environments.md) | +| Using one response inside the next request; template functions | [chaining.md](references/chaining.md) | +| OpenAPI/Postman/Insomnia/cURL import, exporting workspaces | [import-export.md](references/import-export.md) | + +## Execution rules + +1. Resolve the workspace before mutating. Do not create a second workspace when + an existing one matches the user's project. +2. Read the schema before writing a non-trivial JSON payload. Do not guess field + names. +3. Prefer `update` merge patches over re-sending whole objects. +4. Deletes require `--yes` in a non-interactive shell; otherwise they block on a + prompt. Confirm intent with the user before deleting anything. +5. Never write a real secret into an environment variable on the user's behalf. + Reference one (`${[ api_token ]}`) and let the user fill in the value — see + [environments.md](references/environments.md). +6. After creating requests, verify by sending one, and report the actual HTTP + status from `-v` rather than inferring success from exit code 0. +7. Requests you create are permanent user data in their app, not scratch. Name + them the way the user would, and clean up anything you created purely to test. diff --git a/crates-cli/yaak-cli/skills/use-yaak/references/chaining.md b/crates-cli/yaak-cli/skills/use-yaak/references/chaining.md new file mode 100644 index 00000000..98241750 --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/references/chaining.md @@ -0,0 +1,115 @@ +# Chaining requests + +The usual way to feed one response into the next request is a shell pipeline: +send, pipe through `jq`, stash in a variable, interpolate into the next command. +Yaak does not need that. A request can reference another request's response +directly, and Yaak resolves the dependency at send time — including sending the +upstream request first if it has to. + +This is the highest-leverage thing the CLI offers, because the chain is stored +in the workspace. The user can re-run it from the app, and it keeps working +after the shell session is gone. + +## Reading a value out of another response + +``` +${[ response.body.path(request='rq_login', path='$.token') ]} +``` + +`request` is the upstream request's ID. `path` is JSONPath for JSON responses +and XPath for XML. So a login-then-call-the-API pair is two requests and no glue: + +```bash +yaak request create wk_abc123 --json '{ + "name": "Login", + "method": "POST", + "url": "${[ base_url ]}/auth/login", + "bodyType": "application/json", + "body": {"text": "{\"user\":\"demo\",\"pass\":\"${[ password ]}\"}"}, + "headers": [{"name": "Content-Type", "value": "application/json", "enabled": true}] +}' +# -> Created request: rq_login + +yaak request create wk_abc123 --json '{ + "name": "List Orders", + "method": "GET", + "url": "${[ base_url ]}/orders", + "authenticationType": "bearer", + "authentication": {"token": "${[ response.body.path(request='rq_login', path='$.token') ]}"} +}' +``` + +Sending "List Orders" now sends "Login" first when it needs to, extracts +`$.token`, and puts it in the `Authorization` header. + +Note the quoting: template function arguments use **single quotes**, so inside a +single-quoted shell string write the payload to a file, or escape as above, or +switch the outer shell quoting to double quotes and escape the inner JSON. +Writing the JSON payload to a file and using `--json "$(cat payload.json)"` is +the least error-prone for anything complex. + +## Controlling when the upstream request re-sends + +The `behavior` argument decides whether the dependency is actually sent: + +| `behavior` | Meaning | +|---|---| +| `smart` (default) | Send only if there is no stored response yet | +| `always` | Send every time | +| `ttl` | Send if the newest response is older than `ttl` seconds (`0` never expires) | + +``` +${[ response.body.path(request='rq_login', path='$.token', behavior='ttl', ttl='300') ]} +``` + +`smart` is right for a token you fetch once. `ttl` matches a real token lifetime +and is usually the best choice for auth. `always` is for values that must be +fresh on every call, like a nonce. + +## Other response accessors + +``` +${[ response.header(request='rq_login', header='X-Request-Id') ]} +${[ response.body.raw(request='rq_login') ]} +``` + +`response.body.path` also accepts `behavior`/`ttl`, and has an alias of plain +`response`. + +## Other useful template functions + +These come from bundled plugins and work anywhere a value is rendered: + +| Function | Use | +|---|---| +| `uuid.v4()`, also `v1`, `v3`, `v5`, `v6`, `v7` | Idempotency keys, unique record names | +| `timestamp.unix()`, `timestamp.unixMillis()`, `timestamp.iso8601()` | Timestamps in bodies or signatures | +| `timestamp.format(...)`, `timestamp.offset(...)` | Formatted or relative times | +| `random.range(min='1', max='100', decimals='0')` | Sample data | +| `hash.sha256(input='…', encoding='hex')` | Digests — also `md5`, `sha1`, `sha512` | +| `hmac.sha256(input='…', key='…', encoding='hex')` | Signed request signatures | +| `base64.encode(input='…')`, `base64.decode(...)` | Encoded values | +| `url.encode(input='…')`, `url.decode(...)` | Escaping values for URLs | +| `fs.readFile(path='/abs/path', trim='true')` | Pull a value from a file on disk | +| `cookie.value(name='session')` | Read a cookie from the jar | +| `1password.item(...)` | Fetch a secret from 1Password rather than storing it | + +Note the shapes that are easy to misremember: the encoders are `base64.encode` +and `url.encode`, not `encode.base64`; the random function is `random.range`, +not `random.number`; and there is no `timestamp.now`. Argument names vary per +function, and a wrong name renders as an error rather than an empty string, so +check the function in the app's template editor when unsure. + +## When to chain and when not to + +Chain when the dependency is part of the API's real shape: log in, then call; +create a resource, then fetch it by the returned ID. The workspace becomes a +runnable description of the API, which is the point. + +Do not chain to smuggle in shell logic. If a value needs a conditional, a +computation, or a retry, do that in the shell and set an environment variable. +And do not build a long chain just to run a group of requests — `yaak send +` already sends every request in a folder sequentially, with +`--fail-fast` to stop at the first failure and `--parallel` when order does not +matter. Reach for a chain when a request genuinely *depends* on another's +response, not merely when it should run after it. diff --git a/crates-cli/yaak-cli/skills/use-yaak/references/environments.md b/crates-cli/yaak-cli/skills/use-yaak/references/environments.md new file mode 100644 index 00000000..971bab27 --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/references/environments.md @@ -0,0 +1,122 @@ +# Environments and variables + +## The hierarchy + +Every workspace gets a base environment named **Global Variables** for free. Its +`parentModel` is `workspace`, and its variables apply to every send regardless of +which environment is selected. + +Sub-environments (`parentModel: "environment"`) sit under the base and are chosen +per send with `-e`. A sub-environment variable overrides a base variable of the +same name; names it does not define fall through to the base. + +```bash +yaak environment list wk_abc123 +# ev_staging - Staging (environment) +# ev_production - Production (environment) +# ev_base - Global Variables (workspace) +``` + +The trailing parenthetical is `parentModel`, which is how you tell the base +environment from the rest. + +## Setting variables + +Variables are an array on the environment, so an update replaces the whole list. +Read first, then write the full array back: + +```bash +yaak environment show ev_base +yaak environment update --json '{ + "id": "ev_base", + "variables": [ + {"name": "base_url", "value": "https://api.example.com", "enabled": true}, + {"name": "api_version", "value": "v1", "enabled": true} + ] +}' +``` + +Create a sub-environment with its variables in one step: + +```bash +yaak environment create wk_abc123 --json '{ + "name": "Staging", + "parentModel": "environment", + "variables": [{"name": "base_url", "value": "https://staging.example.com", "enabled": true}] +}' +``` + +`enabled: false` keeps a variable defined but inert, which is how the app models +a commented-out value. + +## Using them + +`${[ name ]}` resolves anywhere a value is rendered — URL, headers, body, and +authentication fields: + +```bash +yaak request create wk_abc123 --json '{ + "name": "List Pets", + "method": "GET", + "url": "${[ base_url ]}/${[ api_version ]}/pets", + "authenticationType": "bearer", + "authentication": {"token": "${[ api_token ]}"} +}' +``` + +Then run the same request against different targets: + +```bash +yaak request send rq_abc123 # base environment only +yaak -e ev_staging request send rq_abc123 # staging overrides base_url +yaak -e ev_production send fl_smoke_tests # whole folder against production +``` + +`-e` is global, so it applies to `send`, `request send`, folder sends, and +workspace sends alike. + +## Unresolved variables fail loudly + +Referencing a name no active environment defines aborts before anything is sent: + +``` +Error: Failed to render request templates: Render Error: Variable "api_token" is not defined in active environment +``` + +Exit code 1. This is a feature worth relying on — a typo in a variable name can +never quietly send a request to `https:///pets`. When a send fails this way, the +fix is either the variable name in the request or the `-e` environment, not a +retry. + +## Secrets + +Do not write real credentials into environment variables on the user's behalf. +Create the reference and let the user supply the value: + +```bash +yaak environment update --json '{"id":"ev_base","variables":[{"name":"api_token","value":"","enabled":true}]}' +``` + +Then tell the user which variable to fill in, and in which environment. + +Environments have a `public` flag that mirrors the app's Sharable/Private toggle. +`public: false` (the default) marks the environment Private, which keeps it out +of `yaak export` unless `--include-private-environments` is passed. Secrets +belong in a private environment; values safe to commit or share belong in a +sharable one. + +## Cookies + +Cookie jars are per-workspace and require no setup — the oldest jar is used +automatically. Sending cookies is off by default per request; enable it with the +inherited setting, on the folder if it should apply to a whole group: + +```json +"settingSendCookies": {"enabled": true, "value": true}, +"settingStoreCookies": {"enabled": true, "value": true} +``` + +With both on, a login request stores its `Set-Cookie` and later requests in the +same jar send it back, which is often simpler than threading a token by hand. +Use `--cookie-jar cj_abc123` to select a non-default jar, and +`yaak cookie-jar list ` to see the jars and their cookie counts. diff --git a/crates-cli/yaak-cli/skills/use-yaak/references/import-export.md b/crates-cli/yaak-cli/skills/use-yaak/references/import-export.md new file mode 100644 index 00000000..4399f6cd --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/references/import-export.md @@ -0,0 +1,71 @@ +# Importing and exporting + +## Import + +```bash +yaak import ./openapi.yaml +``` + +One command, one positional file path. The format is auto-detected — OpenAPI 3, +Swagger 2, Postman collections, Postman environments, Insomnia exports, cURL +commands, and Yaak's own export format are all supported by bundled importer +plugins. Output is a one-line summary: + +``` +Imported 1 workspace, 1 environment, 1 folder, 3 HTTP requests +``` + +By default this creates a **new workspace**. To merge into one that already +exists: + +```bash +yaak import ./collection.json --workspace-id wk_abc123 +``` + +Not every importer honours `--workspace-id`; the flag applies where the importer +supports it, and otherwise a new workspace is still created. Run +`yaak workspace list` afterwards to see what you actually got. + +**Reach for this first.** When the user has an OpenAPI spec, a Postman +collection, or even a directory of `curl` commands in a README, importing beats +authoring requests by hand — it is one command, it preserves names and grouping, +and it will not typo a URL. Author requests by hand when there is no spec, or +when the user wants a small hand-picked set rather than every endpoint. + +A cURL import is a fast way to turn something the user already has into a saved +request: + +```bash +echo "curl -X POST https://api.example.com/pets -H 'Content-Type: application/json' -d '{\"name\":\"Rex\"}'" > /tmp/req.txt +yaak import /tmp/req.txt +``` + +## After importing + +An imported spec gives you requests pointing at whatever `servers` the spec +declared. The usual follow-up is to make the host swappable: + +1. `yaak request list ` to see what landed. +2. Put the host in a base environment variable (`base_url`). +3. Update the imported requests to use `${[ base_url ]}`, then add a + sub-environment per deployment target. + +See [environments.md](environments.md). At that point `yaak -e ev_staging send +` runs the whole imported API against staging. + +## Export + +```bash +yaak export ./backup.json # the only workspace, when there is one +yaak export ./backup.json wk_abc123 # a specific workspace +yaak export ./backup.json wk_abc wk_def # several +yaak export ./backup.json --all # everything +``` + +Private environments are **excluded** unless you pass +`--include-private-environments`. That default exists so an export can be +committed to a repository without leaking credentials — do not add the flag +just to make an export look complete. Add it only when the user explicitly wants +a full backup, and say so when you do. + +The output is Yaak's own format, so `yaak import ./backup.json` round-trips it. diff --git a/crates-cli/yaak-cli/skills/use-yaak/references/requests.md b/crates-cli/yaak-cli/skills/use-yaak/references/requests.md new file mode 100644 index 00000000..cc67223c --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/references/requests.md @@ -0,0 +1,195 @@ +# Authoring requests + +Everything here is a field on the HTTP request model. Run +`yaak request schema http --pretty` to see the full, current schema, including +the authentication variants contributed by installed plugins. + +## Two ways to create + +Name, method, and URL have flags. Everything else needs JSON: + +```bash +yaak request create wk_abc123 --name "List Pets" --method GET --url "https://api.example.com/pets" +``` + +```bash +yaak request create wk_abc123 --json '{ + "name": "Create Pet", + "method": "POST", + "url": "https://api.example.com/pets", + "bodyType": "application/json", + "body": {"text": "{\"name\":\"Rex\",\"species\":\"dog\"}"}, + "headers": [{"name": "Content-Type", "value": "application/json", "enabled": true}] +}' +``` + +Flags and JSON cannot be mixed on the same command. The workspace ID may be +omitted when only one workspace exists, and may also be carried inside the +payload as `workspaceId`. + +## Bodies + +`bodyType` decides how `body` is encoded onto the wire, and `body` is a +free-form object whose shape depends on that type. For text-ish types the +content lives in `body.text` as a **string**, so a JSON payload is +double-encoded: a JSON string containing JSON. + +| `bodyType` | `body` | +|---|---| +| `application/json` | `{"text": "{\"key\":\"value\"}"}` | +| `text/xml` | `{"text": ""}` | +| `other` | `{"text": "…"}` | +| `graphql` | `{"query": "{ pets { id } }", "variables": "{\"n\":1}", "operationName": ""}` | +| `application/x-www-form-urlencoded` | `{"form": [{"name":"a","value":"1","enabled":true}]}` | +| `multipart/form-data` | `{"form": [{"name":"file","file":"/abs/path","enabled":true}]}` | +| `binary` | `{"filePath": "/abs/path"}` | +| `null` (omitted) | no body | + +For `graphql`, note that `variables` is a **string** of JSON, not an object, and +that a GraphQL request sent with method `GET` moves query/variables/operationName +into the query string and sends no body at all. + +For `multipart/form-data`, each entry is either a text field (`value`) or a file +(`file`, an absolute path), and may carry its own `contentType`. + +**Add the `Content-Type` header yourself.** In the desktop app, choosing a body +type also writes a matching `Content-Type` into the request's headers, so it is +stored on the request rather than inferred at send time. Creating a request from +the CLI skips that step: `bodyType` alone controls how the body is *encoded*, and +nothing adds the header. A JSON body with no `Content-Type` goes out as untyped +bytes, which many APIs answer with 400 or 415. + +```json +"bodyType": "application/json", +"body": {"text": "{\"name\":\"Rex\"}"}, +"headers": [{"name": "Content-Type", "value": "application/json", "enabled": true}] +``` + +Use the same value as `bodyType`, with two exceptions the app also makes: `other` +pairs with `text/plain`, and `graphql` pairs with `application/json`. Multipart is +the one case to leave alone — the sender replaces that header with one carrying +the generated boundary. + +Requests created this way end up identical to app-created ones, which matters +because the user will open them in the app afterwards. + +## Headers + +```json +"headers": [ + {"name": "Accept", "value": "application/json", "enabled": true}, + {"name": "X-Debug", "value": "1", "enabled": false} +] +``` + +`enabled: false` keeps a header in the app for the user to toggle without +sending it. Values accept template variables. + +## URL parameters + +One array covers both query string entries and path placeholders. A parameter +fills a path placeholder only when its **name starts with a colon** and matches +the placeholder in the URL. Everything else becomes a query string entry: + +```json +"url": "https://api.example.com/pets/:petId/visits", +"urlParameters": [ + {"name": ":petId", "value": "42", "enabled": true}, + {"name": "limit", "value": "10", "enabled": true} +] +``` + +That sends `https://api.example.com/pets/42/visits?limit=10` — `:petId` is +substituted into the path and dropped from the query string, `limit` is not. + +This is the single easiest thing to get wrong here, and it fails **silently**. +Naming the parameter `petId` instead of `:petId` leaves `/pets/:petId/visits` in +the path as literal text and appends `?petId=42`, which most servers answer with +a 404. Always include the colon, and confirm with `yaak -v request send ` +that the `> GET …` line shows a substituted path. + +## Authentication + +`authenticationType` names a strategy and `authentication` holds its values. The +strategy names are not always what you would guess — the built-ins are `basic`, +`bearer`, `apikey`, `jwt`, `oauth1`, `oauth2`, `awsv4` (not "aws"), and +`windows` (not "ntlm"). Installed plugins can add more. + +```json +"authenticationType": "bearer", +"authentication": {"token": "${[ api_token ]}"} +``` + +```json +"authenticationType": "basic", +"authentication": {"username": "admin", "password": "${[ admin_password ]}"} +``` + +```json +"authenticationType": "apikey", +"authentication": {"location": "header", "key": "X-Api-Key", "value": "${[ api_key ]}"} +``` + +The exact fields for each strategy, and which are required, are in the +`authentication` property of `yaak request schema http --pretty`, which +enumerates every installed strategy as a named variant. Read it rather than +guessing — `oauth2` has fifteen fields and `jwt` requires four. + +Set `authenticationType` to `null` to send no auth and stop inheriting from the +parent folder. + +## Folders and inheritance + +Folders are containers *and* a place to put shared configuration. Headers and +authentication set on a folder apply to every request inside it, so the common +pattern is one folder per API surface holding the auth: + +```bash +yaak folder create wk_abc123 --name "Admin API" +yaak folder update --json '{ + "id": "fl_abc123", + "authenticationType": "bearer", + "authentication": {"token": "${[ admin_token ]}"}, + "headers": [{"name": "X-Api-Version", "value": "2024-01-01", "enabled": true}] +}' +yaak request create wk_abc123 --json '{"name":"List Users","method":"GET","url":"${[ base_url ]}/users","folderId":"fl_abc123"}' +``` + +The request above sends both the folder's bearer token and its version header +without repeating either. A request that sets its own `authenticationType` +overrides the folder's. + +Nest folders by setting a folder's `folderId`. `yaak send ` sends every +request in the folder recursively. + +## Per-request settings + +Each `setting*` field is an inherited toggle shaped +`{"enabled": bool, "value": …}`, where `enabled` means "override the inherited +value" rather than "turn the feature on": + +```json +"settingFollowRedirects": {"enabled": true, "value": false}, +"settingRequestTimeout": {"enabled": true, "value": 5000} +``` + +Available: `settingFollowRedirects`, `settingRequestTimeout` (ms, `0` for none), +`settingValidateCertificates`, `settingSendCookies`, `settingStoreCookies`. + +## Updating + +Updates are JSON merge patches keyed by `id`. Send only what changes: + +```bash +yaak request update --json '{"id":"rq_abc123","method":"PATCH"}' +``` + +Arrays are replaced wholesale, not merged — to add one header, read the current +list with `yaak request show rq_abc123` and write the full new array back. +Setting a key to `null` removes it. + +## Ordering + +`sortPriority` (a float) controls display order in the app sidebar. Leave it at +`0` unless the user cares; requests created with the same priority fall back to +creation order. diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index a1417760..27a50f85 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; - Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]} - View JSONSchema for models before creating or updating (eg. `yaak request schema http`) - Deletion requires confirmation (--yes for non-interactive environments) + - Run `yaak agent install` to install the Yaak skill for AI coding agents "#)] pub struct Cli { /// Use a custom data directory @@ -39,6 +40,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Install Yaak skills for AI coding agents + Agent(AgentArgs), + /// Authentication commands Auth(AuthArgs), @@ -84,6 +88,35 @@ pub enum Commands { Environment(EnvironmentArgs), } +#[derive(Args)] +pub struct AgentArgs { + #[command(subcommand)] + pub command: AgentCommands, +} + +#[derive(Subcommand)] +pub enum AgentCommands { + /// Install the Yaak skill so coding agents know how to drive the CLI + #[command(alias = "update", alias = "add")] + Install { + /// Overwrite skill files you have edited locally + #[arg(long)] + force: bool, + + /// Install for specific agents instead of all detected ones + #[arg(long = "agent", value_name = "AGENT")] + agent: Option>, + }, + + /// Remove the Yaak skill + #[command(alias = "uninstall", alias = "rm")] + Remove { + /// Remove for specific agents instead of all detected ones + #[arg(long = "agent", value_name = "AGENT")] + agent: Option>, + }, +} + #[derive(Args)] pub struct SendArgs { /// Request, folder, or workspace ID diff --git a/crates-cli/yaak-cli/src/commands/agent.rs b/crates-cli/yaak-cli/src/commands/agent.rs new file mode 100644 index 00000000..c9815038 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/agent.rs @@ -0,0 +1,204 @@ +use crate::cli::{AgentArgs, AgentCommands}; +use crate::ui; +use crate::version; +use include_dir::{Dir, include_dir}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +static SKILL_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills/use-yaak"); + +const SKILL_NAME: &str = "use-yaak"; +const MANIFEST_NAME: &str = ".yaak-skill.json"; + +type CommandResult = std::result::Result; + +/// Records what this CLI wrote, so a later install can tell its own output apart +/// from edits the user made by hand. +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SkillManifest { + cli_version: String, + /// Relative file path -> SHA-256 of the contents this CLI wrote. + files: BTreeMap, +} + +/// A coding tool that reads skills from a directory in the user's home. +struct Target { + /// Display name used in output. + label: &'static str, + /// Directory holding all skills for this tool (`…/skills`). + skills_dir: PathBuf, +} + +pub fn run(args: AgentArgs) -> i32 { + let result = match args.command { + AgentCommands::Install { force, agent } => install(force, agent), + AgentCommands::Remove { agent } => remove(agent), + }; + + match result { + Ok(()) => 0, + Err(error) => { + ui::error(&error); + 1 + } + } +} + +fn install(force: bool, agent: Option>) -> CommandResult { + let targets = resolve_targets(agent)?; + + let mut installed = 0usize; + for target in &targets { + let dir = target.skills_dir.join(SKILL_NAME); + match write_skill(&dir, force) { + Ok(WriteOutcome::Written { skipped }) => { + installed += 1; + ui::success(&format!("{} -> {}", target.label, dir.display())); + for path in skipped { + ui::warning(&format!(" kept your edited {path} (use --force to overwrite)")); + } + } + Err(error) => ui::warning_stderr(&format!("{}: {}", target.label, error)), + } + } + + if installed == 0 { + return Err("Failed to install the Yaak skill anywhere".to_string()); + } + + ui::info("Restart your coding tool to pick up the skill"); + Ok(()) +} + +fn remove(agent: Option>) -> CommandResult { + let targets = resolve_targets(agent)?; + + let mut removed = 0usize; + for target in &targets { + let dir = target.skills_dir.join(SKILL_NAME); + if !dir.exists() { + continue; + } + match fs::remove_dir_all(&dir) { + Ok(()) => { + removed += 1; + ui::success(&format!("Removed {}", dir.display())); + } + Err(error) => { + ui::warning_stderr(&format!("Failed to remove {}: {error}", dir.display())) + } + } + } + + if removed == 0 { + ui::info("No Yaak skill was installed"); + } + Ok(()) +} + +enum WriteOutcome { + Written { skipped: Vec }, +} + +fn write_skill(dir: &Path, force: bool) -> CommandResult { + let previous = read_manifest(dir); + let mut manifest = + SkillManifest { cli_version: version::cli_version().to_string(), ..Default::default() }; + let mut skipped = Vec::new(); + + for file in walk(&SKILL_DIR) { + let relative = file.path().to_string_lossy().to_string(); + let destination = dir.join(file.path()); + let contents = file.contents(); + let digest = sha256(contents); + + // Leave a file alone when the user has changed it since we wrote it. + if !force + && destination.exists() + && let Ok(on_disk) = fs::read(&destination) + && let Some(written) = previous.files.get(&relative) + && sha256(&on_disk) != *written + { + skipped.push(relative.clone()); + manifest.files.insert(relative, written.clone()); + continue; + } + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + fs::write(&destination, contents) + .map_err(|e| format!("Failed to write {}: {e}", destination.display()))?; + manifest.files.insert(relative, digest); + } + + let manifest_json = serde_json::to_string_pretty(&manifest) + .map_err(|e| format!("Failed to serialize skill manifest: {e}"))?; + fs::write(dir.join(MANIFEST_NAME), manifest_json) + .map_err(|e| format!("Failed to write skill manifest: {e}"))?; + + Ok(WriteOutcome::Written { skipped }) +} + +fn read_manifest(dir: &Path) -> SkillManifest { + fs::read_to_string(dir.join(MANIFEST_NAME)) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() +} + +fn sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Flatten the embedded skill directory into its files, recursing into subdirectories. +fn walk<'a>(dir: &'a Dir<'a>) -> Vec<&'a include_dir::File<'a>> { + let mut files: Vec<_> = dir.files().collect(); + for child in dir.dirs() { + files.extend(walk(child)); + } + files +} + +/// `~/.agents/skills` is the cross-tool location and is always written. Tool-specific +/// directories are written only when that tool is already set up on this machine, so +/// installing never creates a config directory for a tool the user does not use. +fn resolve_targets(requested: Option>) -> CommandResult> { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + + let known: Vec<(&str, PathBuf, PathBuf)> = vec![ + ("agents", home.join(".agents"), home.join(".agents").join("skills")), + ("claude-code", home.join(".claude"), home.join(".claude").join("skills")), + ("cursor", home.join(".cursor"), home.join(".cursor").join("skills")), + ("codex", home.join(".codex"), home.join(".codex").join("skills")), + ("opencode", home.join(".opencode"), home.join(".opencode").join("skills")), + ]; + + if let Some(requested) = requested { + let mut targets = Vec::new(); + for name in requested { + let found = + known.iter().find(|(label, _, _)| *label == name.as_str()).ok_or_else(|| { + let names: Vec<_> = known.iter().map(|(l, _, _)| *l).collect(); + format!("Unknown agent '{name}'. Known agents: {}", names.join(", ")) + })?; + targets.push(Target { label: found.0, skills_dir: found.2.clone() }); + } + return Ok(targets); + } + + let targets: Vec = known + .into_iter() + .filter(|(label, marker, _)| *label == "agents" || marker.exists()) + .map(|(label, _, skills_dir)| Target { label, skills_dir }) + .collect(); + + Ok(targets) +} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index d19d9c9e..aad62221 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod auth; pub mod cookie_jar; pub mod environment; diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index a14e42f0..4fc2b9d2 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -119,7 +119,7 @@ fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) { if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) { append_description( url_schema, - "For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).", + "For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names that keep the leading `:` (for example `:id`, `:commentId`). A name without the `:` is sent as a query string parameter instead, leaving the placeholder in the path.", ); } } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index d2630e11..05d7a712 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -36,6 +36,7 @@ async fn main() { version_check::maybe_check_for_updates().await; let exit_code = match command { + Commands::Agent(args) => commands::agent::run(args), Commands::Auth(args) => commands::auth::run(args).await, Commands::Import(args) => { let mut context = CliContext::new(data_dir.clone(), app_id);