Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ cargo build --release --locked
| Formatting | `src/format`, `src/format/content_type.rs`, `src/image` | MIME-to-formatter policy, streaming formatters, built-in image defaults; external image adapters only for `--image external`/config and must be bounded. |
| gRPC/protobuf | `src/grpc`, `src/proto` | Framing/status/reflection, local schema/discovery/conversion/JSON streams. Reuse standard gRPC headers/status/framed-body helpers. |
| WebSocket | `src/websocket` | Interactive and non-interactive message loops; custom dialer for DNS/proxy/TLS. |
| Auth/session/update | `src/auth`, `src/session.rs`, `src/update`, `install.sh` | Auth helpers; locked cookie sessions; HTTPS self-update with checksum/archive validation and no origin-specific TLS overrides. |
| Auth/session/update | `src/auth`, `src/session.rs`, `src/update`, `src/skill.rs`, `install.sh` | Auth helpers; locked cookie sessions; HTTPS self-update; offline embedded Agent Skill installation with modification detection. |
| Tests | `tests/`, `tests/support/` | Integration tests run the compiled binary; support code is split by domain. `run_fetch` isolates HTTP/3 cache by default. `TestServer`/`H3TestServer` use `mpsc` channel notification (not polling). `wait_for_requests` blocks via `recv_timeout` on the notification channel. |

Request flow: CLI parse → config merge → request build (gRPC may load/reflect schema and frame protobuf) → transport execute → response format/output/pager/clipboard.
Expand Down Expand Up @@ -128,6 +128,7 @@ Request flow: CLI parse → config merge → request build (gRPC may load/reflec
- Session saves lock per session, reload latest JSON, merge only local cookie changes, atomically replace, and warn on bounded lock wait. Update locks use `fileutil::FileLock`; background checks are nonblocking.
- Self-update metadata/artifact/checksum/redirect URLs require HTTPS except internal test overrides; update networking must not inherit origin-specific TLS/version/Unix-socket config, but may keep proxy/DNS/timeouts/verbosity/custom CA. Artifacts stream with SHA-256; tar extracts streaming, zip uses temp archive; Unix replacement preserves atomic parent-dir sync.
- `install.sh` verifies `.sha256`; completion install is opt-in (`--completions` or `FETCH_INSTALL_COMPLETIONS=1`) and must not auto-edit shell startup files by default.
- Agent Skill files live under `skills/fetch/` and are embedded by `src/skill.rs`. User/project installs support generic `.agents/skills/fetch` plus distinct Codex, Claude, Gemini, and Pi directories, record `.fetch-skill.json` in each copy, use atomic file helpers and a parent-directory operation lock, revalidate before writes/deletions, refuse modified installs without `--force`, reject unrelated CLI options rather than silently ignoring them, and leave no lock/directory artifacts when uninstall targets are all missing. Never add network downloads or agent-config edits to skill installation.
- Ctrl-C/SIGINT exits 130, including streaming modes. Output downloads keep `*.download` temps under a drop guard.
- Rust is pinned to 1.97.0 (`rust-toolchain.toml`); keep `Cargo.toml` rust-version and CI aligned. Windows config search prefers XDG/HOME paths before AppData; Windows mTLS fixtures use RSA certs.
- GitHub Actions run fmt/clippy/unit/integration. Release builds archive names for self-updater, Linux GNU uses `cargo-zigbuild` with glibc 2.28 floor, Windows uses static MSVC CRT, archives get SHA-256 sidecars, `FETCH_VERSION` comes from release tag/manual version (local: matching `v*`, then `git describe`, then `v0.0.0-dev`), and `vcs.modified` ignores untracked files.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ inspection, and request timing in one CLI.
- **DNS inspection** - Inspect hostname resolution, record families, TTLs, and resolver timing
- **Timing waterfall** - Visualize request timing phases (DNS, TCP, TLS, TTFB, transfer) with a waterfall chart
- **Configuration** - Global and per-host configuration file support
- **Agent skill** - Bundled offline skill installation for Codex, Claude Code, Gemini CLI, and Pi

## Quick Start

Expand Down Expand Up @@ -53,6 +54,26 @@ fetch -v -h
> `fetch -v --help`) to open the full, colorized command menu with detailed
> options, examples, and pager support.

#### Install the agent skill

```sh
# Use the interoperable ~/.agents/skills/fetch location
fetch --install-skill

# Or install directly for one agent
fetch --install-skill pi

# Install all generic and agent-specific locations
fetch --install-skill all

# Preview a project-local installation
fetch --install-skill all --scope project --dry-run
```

The skill is embedded in `fetch`, so installation is offline. The installer
shows destinations before writing, detects modified installations, and does not
edit agent configuration files. See [the CLI reference](docs/cli-reference.md#agent-skill-options).

## Output Model

`fetch` keeps response bodies and metadata separate: the body is written to
Expand Down
31 changes: 31 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,37 @@ Update fetch binary in place. Use with `--dry-run` to check for updates without
See [Updates](updates.md) for release source, verification, permissions,
background auto-update behavior, and cache/lock files.

### Agent skill options

The fetch Agent Skill is embedded in the binary and can be installed offline.
Choose an agent-specific location or the interoperable `agents` location:

| Target | User scope | Project scope |
| --- | --- | --- |
| `agents` (default) | `~/.agents/skills/fetch` | `.agents/skills/fetch` |
| `codex` | `~/.codex/skills/fetch` | `.codex/skills/fetch` |
| `claude` | `~/.claude/skills/fetch` | `.claude/skills/fetch` |
| `gemini` | `~/.gemini/skills/fetch` | `.gemini/skills/fetch` |
| `pi` | `~/.pi/agent/skills/fetch` | `.pi/skills/fetch` |

```sh
fetch --skill # print SKILL.md
fetch --install-skill [agents|codex|claude|gemini|pi|all]
fetch --uninstall-skill [agents|codex|claude|gemini|pi|all]
```

User scope is the default; use `--scope project` for the project locations
shown above. Install and uninstall commands show every destination before
changing it and ask for confirmation when attached to a terminal. `--dry-run`
previews changes and `--force` permits replacing or removing a locally modified
installation. No agent configuration files are changed and installation
performs no network requests.

Each installed copy contains `.fetch-skill.json`, recording the skill version,
fetch version, and hashes used to detect local modifications. `all` means the
five locations listed above; it does not probe and write to additional
directories.

### `--complete SHELL`

Output shell completion scripts. Values: `bash`, `fish`, `zsh`.
Expand Down
1 change: 1 addition & 0 deletions skills/fetch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ license: MIT
compatibility: Requires the fetch executable and network access.
metadata:
repository: https://github.com/ryanfowler/fetch
skill-version: "1"
---

# fetch
Expand Down
16 changes: 16 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ async fn run(mut cli: Cli) -> Result<i32, RuntimeErrorWithColor> {
}

async fn run_inner(cli: &mut Cli) -> Result<i32, FetchError> {
// Validate skill actions before other metadata dispatch so combinations such
// as `--skill --complete` cannot silently choose whichever branch runs first.
if crate::skill::is_action(cli) {
crate::skill::validate_cli(cli)?;
}

if let Some(shell) = cli.complete.as_deref() {
let output =
crate::cli::completion::output(shell, &cli.extra_args).map_err(FetchError::Message)?;
Expand All @@ -292,6 +298,16 @@ async fn run_inner(cli: &mut Cli) -> Result<i32, FetchError> {

normalize_extra_args(cli)?;

if crate::skill::is_action(cli) {
return crate::skill::execute(cli);
}
if cli.scope.is_some() {
return Err("flag '--scope' requires a skill action".into());
}
if cli.force {
return Err("flag '--force' requires a skill action".into());
}

if cli.help || cli.version || cli.buildinfo {
crate::config::apply_best_effort(cli);
if cli.help {
Expand Down
37 changes: 37 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ pub struct Cli {
#[arg(long = "dry-run", help = "Print out the request info and exit")]
pub dry_run: bool,

#[arg(long, help = "Overwrite a modified skill installation")]
pub force: bool,

#[arg(
long = "ech",
value_name = "MODE",
Expand Down Expand Up @@ -336,6 +339,17 @@ pub struct Cli {
#[arg(long = "inspect-tls", help = "Inspect the TLS certificate chain")]
pub inspect_tls: bool,

#[arg(
long = "install-skill",
value_name = "AGENT",
num_args = 0..=1,
default_missing_value = "auto",
value_parser = ["auto", "agents", "codex", "claude", "gemini", "pi", "all"],
hide_possible_values = true,
help = "Install the bundled agent skill"
)]
pub install_skill: Option<String>,

#[arg(
short = 'j',
long,
Expand Down Expand Up @@ -495,6 +509,18 @@ pub struct Cli {
#[arg(short = 's', long, help = "Print only errors to stderr")]
pub silent: bool,

#[arg(
long,
value_name = "SCOPE",
value_parser = ["user", "project"],
hide_possible_values = true,
help = "Skill installation scope [user, project]"
)]
pub scope: Option<String>,

#[arg(long, help = "Print the bundled SKILL.md")]
pub skill: bool,

#[arg(long = "sort-headers", help = "Sort displayed headers by name")]
pub sort_headers: bool,

Expand Down Expand Up @@ -523,6 +549,17 @@ pub struct Cli {
#[arg(long, help = "Update the fetch binary in place")]
pub update: bool,

#[arg(
long = "uninstall-skill",
value_name = "AGENT",
num_args = 0..=1,
default_missing_value = "auto",
value_parser = ["auto", "agents", "codex", "claude", "gemini", "pi", "all"],
hide_possible_values = true,
help = "Uninstall the agent skill"
)]
pub uninstall_skill: Option<String>,

#[arg(
short = 'v',
long = "verbose",
Expand Down
62 changes: 62 additions & 0 deletions src/cli/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ const COMPRESS_VALUES: &[FlagValue] = &[
value: "Disable compression negotiation",
},
];
const AGENT_VALUES: &[FlagValue] = &[
FlagValue {
key: "agents",
value: "Open Agent Skills location",
},
FlagValue {
key: "codex",
value: "Codex",
},
FlagValue {
key: "claude",
value: "Claude Code",
},
FlagValue {
key: "gemini",
value: "Gemini CLI",
},
FlagValue {
key: "pi",
value: "Pi coding agent",
},
FlagValue {
key: "all",
value: "All compatible locations",
},
];
const SCOPE_VALUES: &[FlagValue] = &[
FlagValue {
key: "user",
value: "Install for the current user",
},
FlagValue {
key: "project",
value: "Install in the current project",
},
];
const FORMAT_VALUES: &[FlagValue] = &[
FlagValue {
key: "auto",
Expand Down Expand Up @@ -226,6 +262,7 @@ const FLAGS: &[Flag] = &[
"DNS server IP or DoH URL",
),
flag(None, "dry-run", "", "Print out the request info and exit"),
flag(None, "force", "", "Overwrite a modified skill installation"),
flag(
Some('e'),
"edit",
Expand Down Expand Up @@ -260,6 +297,14 @@ const FLAGS: &[Flag] = &[
"Describe a gRPC service, method, or message",
),
flag(None, "grpc-list", "", "List available gRPC services"),
Flag {
short: None,
long: "install-skill",
args: "AGENT",
description: "Install the bundled agent skill",
aliases: &[],
values: AGENT_VALUES,
},
flag(
Some('H'),
"header",
Expand Down Expand Up @@ -396,6 +441,15 @@ const FLAGS: &[Flag] = &[
"Use a named session for cookies",
),
flag(Some('s'), "silent", "", "Print only errors to stderr"),
Flag {
short: None,
long: "scope",
args: "SCOPE",
description: "Skill installation scope",
aliases: &[],
values: SCOPE_VALUES,
},
flag(None, "skill", "", "Print the bundled SKILL.md"),
flag(None, "sort-headers", "", "Sort displayed headers by name"),
flag(
Some('t'),
Expand All @@ -406,6 +460,14 @@ const FLAGS: &[Flag] = &[
flag(Some('T'), "timing", "", "Display a timing waterfall chart"),
flag(None, "unix", "PATH", "Make the request over a unix socket"),
flag(None, "update", "", "Update the fetch binary in place"),
Flag {
short: None,
long: "uninstall-skill",
args: "AGENT",
description: "Uninstall the agent skill",
aliases: &[],
values: AGENT_VALUES,
},
flag(Some('v'), "verbose", "", "Verbosity of the output"),
flag(Some('V'), "version", "", "Print version"),
Flag {
Expand Down
8 changes: 8 additions & 0 deletions src/flag_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ pub(crate) static FLAGS: &[FlagDef] = &[

// ── convenience iterators ──────────────────────────────────────────────

/// Return registry flag names explicitly set on the CLI.
pub(crate) fn set_flag_names(cli: &Cli) -> impl Iterator<Item = &'static str> + '_ {
FLAGS
.iter()
.filter(move |definition| (definition.is_set)(cli))
.map(|definition| definition.name)
}

/// Push names of all flags in `category` that are set on `cli` into `ignored`.
pub(crate) fn append_ignored_of_category(
cli: &Cli,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub(crate) mod net;
pub mod output;
pub mod proto;
pub mod session;
pub mod skill;
pub mod timing;
pub mod tls;
pub mod update;
Expand Down
Loading
Loading