Files
davila7__claude-code-templates/cli-rust/src/github.rs
Daniel Avila a56202407d feat(cli-rust): Rust port of the CLI core (install path) + npm distribution + CI (#640)
* feat(cli-rust): add Rust port of the CLI core with npm distribution + CI

Adds a new `cli-rust/` crate (binary `cct`) that ports the CORE of the
claude-code-templates CLI to Rust, alongside the existing Node `cli-tool/`.

Native (byte-parity with the Node CLI, verified via `diff -r`):
- Component installation: --agent/--command/--mcp/--setting/--hook/--skill
  and --workflow YAML, replicating the exact GitHub raw/API URLs, target
  paths (flat dir, category dropped), .mcp.json/settings/hooks merge
  semantics, .py/.sh sidecars, 2-space JSON + trailing newline.
- Fire-and-forget tracking (3 endpoints, detached threads, env opt-out).

Delegated to the Node CLI for now (commands/delegate.rs forwards argv via
CCT_NODE_BIN or `npx claude-code-templates@latest`): dashboards, sandbox,
global agents, stats, health-check, interactive setup.

Parity gotcha handled: serde_json Map::remove does swap-remove under
preserve_order; switched to shift_remove to match JS `delete` key order.

Distribution: npm shim (npm/cct/bin/cct.js) execs a prebuilt binary from
optionalDependencies (@davila7/cct-<os>-<arch>, esbuild pattern) so `npx`
keeps working; plus Homebrew, cargo-binstall, and install.sh from GitHub
Releases. build-rust-cli.yml builds all 5 targets on tag `cli-rust-v*`.

Tests: 23 offline unit/integration tests + 2 network integration tests
(#[ignore]). fmt + clippy clean.

CI: rust-ci.yml runs fmt + clippy + tests only when cli-rust/** changes and
posts a sticky PR comment with the results.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli-rust): address cubic review (config safety, opt-out, packaging, CI)

Fixes from the cubic automated review on PR #640:

- install.rs: fail safe on a corrupt/unreadable existing .mcp.json/settings
  file instead of silently overwriting it (MCP aborts; setting/hook skip the
  location), matching Node's throw-on-bad-config behavior.
- tracking.rs: honor both `=1` and `=true` as opt-out values (the README
  documents `=1`); Node only checks `=true`, so this is more privacy-safe.
- cli.rs/main.rs: implement --dry-run for the native install path (prints the
  plan, writes nothing) instead of parsing the flag and ignoring it.
- main.rs: run_prompt surfaces a non-zero `claude` exit code (a missing
  `claude` stays a soft warning since the install already succeeded).
- delegate.rs: propagate signal-based termination as 128+signal on Unix
  instead of collapsing to exit code 1.
- github.rs: warn when a skill file fails to download (mirrors Node's
  "Could not download" log) instead of skipping silently.
- npm/build-packages.mjs: accept the release `.tgz` artifacts (extract them)
  in addition to unpacked binaries, matching build-rust-cli.yml output.
- npm/cct/package.json: add top-level os/cpu so npm rejects unsupported
  platforms at install time instead of failing at runtime.
- build-rust-cli.yml: add `permissions: contents: write` (gh-release) and
  Swatinem/rust-cache for the 5-target matrix.
- tests/integration.rs: tighten the 2-space-indent assertion.

Not changed (intentional parity with the Node CLI):
- merge.rs new-hook-type copy: Node copies the incoming value as-is for a
  previously-absent hook type (no old-format normalization); kept identical.

fmt + clippy clean; 23 offline + 2 network tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli-rust): abort install on corrupt existing settings/hook config

Follow-up to the cubic review: a corrupt/unreadable existing settings or
hook file now fails the whole component install (returns 0) instead of
skipping just that location, matching Node — where readJson throws out of
the per-location loop and fails the component. Prevents a multi-location
run from reporting success despite a bad target location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(cli-rust): prep v0.1.0 release via GitHub Releases + cargo-binstall

Wire up the two opt-in distribution channels that don't touch the existing
`claude-code-templates` npm package:

- Cargo.toml: fix cargo-binstall pkg-url to the `cli-rust-v<version>` tag
  (was `v<version>`), so `cargo binstall --git ...` resolves the release asset.
- install.sh: resolve the latest `cli-rust-v*` release instead of the generic
  /releases/latest, which could otherwise pick a Node-CLI `vX.Y.Z` release.
- README: install table now lists the available preview channels (curl|sh,
  cargo-binstall via --git, from source) and marks Homebrew/npm as planned;
  Release section documents the merge -> tag cli-rust-v0.1.0 -> verify flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:20:46 -04:00

135 lines
4.3 KiB
Rust

//! GitHub fetch helpers: raw component downloads and the recursive contents-API
//! tree walk used by skills.
use crate::constants;
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::time::Duration;
/// Outcome of a raw fetch: found content, an explicit 404, or another HTTP
/// status (treated as an error by callers).
pub enum Fetched {
Ok(String),
NotFound,
Status(u16),
}
fn client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(constants::user_agent())
.build()
.map_err(|e| anyhow!("failed to build HTTP client: {e}"))
}
/// Fetch a raw URL, distinguishing 404 from other failures so callers can show
/// the same "not found" messaging the Node CLI does.
pub fn fetch_raw(url: &str) -> Result<Fetched> {
let resp = client()?.get(url).send()?;
let status = resp.status();
if status.is_success() {
Ok(Fetched::Ok(resp.text()?))
} else if status.as_u16() == 404 {
Ok(Fetched::NotFound)
} else {
Ok(Fetched::Status(status.as_u16()))
}
}
/// Fetch a raw URL, returning `Some(text)` only on 2xx (used for optional
/// sidecar files like `.py`/`.sh` where any failure is silently ignored).
pub fn fetch_raw_optional(url: &str) -> Option<String> {
let resp = client().ok()?.get(url).send().ok()?;
if resp.status().is_success() {
resp.text().ok()
} else {
None
}
}
/// A file downloaded from the skill tree, keyed by its target path under
/// `.claude/skills/<base>/...`.
pub struct DownloadedFile {
pub target_rel_path: String,
pub content: String,
pub executable: bool,
}
/// Recursively download every file under a skill directory via the GitHub
/// contents API. Mirrors `downloadDirectory` in `installIndividualSkill`.
///
/// Returns `Ok(None)` when the top-level path 404s (skill not found); `Ok(Some(
/// files))` otherwise.
pub fn download_skill_tree(
api_url: &str,
skill_base_name: &str,
) -> Result<Option<Vec<DownloadedFile>>> {
let mut files = Vec::new();
let found = walk(api_url, "", skill_base_name, &mut files)?;
if !found {
return Ok(None);
}
Ok(Some(files))
}
fn walk(
api_url: &str,
relative_path: &str,
skill_base_name: &str,
out: &mut Vec<DownloadedFile>,
) -> Result<bool> {
let resp = client()?
.get(api_url)
.header("Accept", "application/vnd.github.v3+json")
.send()?;
if resp.status().as_u16() == 404 {
return Ok(false);
}
if !resp.status().is_success() {
return Err(anyhow!("HTTP {}", resp.status().as_u16()));
}
let contents: Value = resp.json()?;
let items = contents
.as_array()
.ok_or_else(|| anyhow!("unexpected contents API response"))?;
for item in items {
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
let item_path = if relative_path.is_empty() {
name.to_string()
} else {
format!("{relative_path}/{name}")
};
if item_type == "file" {
match item.get("download_url").and_then(|v| v.as_str()) {
Some(download_url) => match fetch_raw_optional(download_url) {
Some(content) => {
let executable = name.ends_with(".py") || name.ends_with(".sh");
out.push(DownloadedFile {
target_rel_path: format!(
".claude/skills/{skill_base_name}/{item_path}"
),
content,
executable,
});
}
// Surface the skipped file (mirrors Node's "Could not
// download" log). SKILL.md absence is caught by the caller.
None => eprintln!("⚠️ Could not download skill file: {item_path}"),
},
None => eprintln!("⚠️ Missing download_url for skill file: {item_path}"),
}
} else if item_type == "dir" {
if let Some(dir_url) = item.get("url").and_then(|v| v.as_str()) {
walk(dir_url, &item_path, skill_base_name, out)?;
}
}
}
Ok(true)
}