mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
a56202407d
* 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>
189 lines
5.9 KiB
Rust
189 lines
5.9 KiB
Rust
//! Anonymous, fire-and-forget download/usage analytics.
|
|
//!
|
|
//! Port of `cli-tool/src/tracking-service.js`. Every call spawns a detached
|
|
//! thread with its own 5s-timeout HTTP client and swallows all errors, so
|
|
//! tracking can never block or break the CLI. Opt-out via `CCT_NO_TRACKING`,
|
|
//! `CCT_NO_ANALYTICS`, or `CI=true`.
|
|
|
|
use crate::constants;
|
|
use serde_json::{json, Value};
|
|
use std::time::Duration;
|
|
|
|
/// Whether tracking is enabled. Honors both `true` and `1` as opt-out values
|
|
/// (the README documents `=1`); Node only checks `=== 'true'`, so this is
|
|
/// intentionally more privacy-protective.
|
|
pub fn enabled() -> bool {
|
|
let off = |k: &str| matches!(std::env::var(k).ok().as_deref(), Some("true") | Some("1"));
|
|
!(off("CCT_NO_TRACKING") || off("CCT_NO_ANALYTICS") || off("CI"))
|
|
}
|
|
|
|
fn post_async(url: &'static str, body: Value) {
|
|
if !enabled() {
|
|
return;
|
|
}
|
|
std::thread::spawn(move || {
|
|
let client = match reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
// Best-effort; ignore all outcomes.
|
|
let _ = client
|
|
.post(url)
|
|
.header("Content-Type", "application/json")
|
|
.header("User-Agent", constants::user_agent())
|
|
.json(&body)
|
|
.send();
|
|
});
|
|
}
|
|
|
|
/// Metadata accompanying a download event. Only the fields the Node payload
|
|
/// actually reads are modeled.
|
|
#[derive(Default)]
|
|
pub struct DownloadMeta {
|
|
pub target_directory: Option<String>,
|
|
pub path: Option<String>,
|
|
pub category: Option<String>,
|
|
}
|
|
|
|
/// Compute the `path` and `category` fields the way `sendToDatabase` does:
|
|
/// `path` falls back to target_directory → path → name; `category` falls back
|
|
/// to metadata or the `category/` prefix of the name, else `"general"`.
|
|
pub fn download_path_category(name: &str, meta: &DownloadMeta) -> (String, String) {
|
|
let path = meta
|
|
.target_directory
|
|
.clone()
|
|
.or_else(|| meta.path.clone())
|
|
.unwrap_or_else(|| name.to_string());
|
|
|
|
let category = meta.category.clone().unwrap_or_else(|| {
|
|
if name.contains('/') {
|
|
name.split('/').next().unwrap_or("general").to_string()
|
|
} else {
|
|
"general".to_string()
|
|
}
|
|
});
|
|
|
|
(path, category)
|
|
}
|
|
|
|
/// `trackDownload` → POST `/api/track-download-supabase`.
|
|
pub fn track_download(component_type: &str, name: &str, meta: &DownloadMeta) {
|
|
let (path, category) = download_path_category(name, meta);
|
|
|
|
let body = json!({
|
|
"type": component_type,
|
|
"name": name,
|
|
"path": path,
|
|
"category": category,
|
|
"cliVersion": constants::CLI_VERSION,
|
|
});
|
|
post_async(constants::TRACK_DOWNLOAD_URL, body);
|
|
}
|
|
|
|
/// Outcome of an installation attempt.
|
|
pub enum Outcome {
|
|
Success,
|
|
Failure,
|
|
#[allow(dead_code)]
|
|
Partial,
|
|
}
|
|
|
|
impl Outcome {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Outcome::Success => "success",
|
|
Outcome::Failure => "failure",
|
|
Outcome::Partial => "partial",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extra fields for `trackInstallationOutcome`.
|
|
#[derive(Default)]
|
|
pub struct OutcomeMeta {
|
|
pub error_type: Option<String>,
|
|
pub error_message: Option<String>,
|
|
pub duration_ms: Option<u128>,
|
|
pub batch_id: Option<String>,
|
|
}
|
|
|
|
/// `trackInstallationOutcome` → POST `/api/track-installation-outcome`.
|
|
pub fn track_outcome(component_type: &str, name: &str, outcome: Outcome, meta: &OutcomeMeta) {
|
|
let body = json!({
|
|
"componentType": component_type,
|
|
"componentName": name,
|
|
"outcome": outcome.as_str(),
|
|
"errorType": meta.error_type,
|
|
"errorMessage": meta.error_message,
|
|
"durationMs": meta.duration_ms.map(|d| d as u64),
|
|
"cliVersion": constants::CLI_VERSION,
|
|
"nodeVersion": Value::Null,
|
|
"platform": std::env::consts::OS,
|
|
"arch": std::env::consts::ARCH,
|
|
"batchId": meta.batch_id,
|
|
});
|
|
post_async(constants::TRACK_OUTCOME_URL, body);
|
|
}
|
|
|
|
/// `trackCommandExecution` → POST `/api/track-command-usage`.
|
|
#[allow(dead_code)]
|
|
pub fn track_command(command: &str, metadata: Value) {
|
|
let body = json!({
|
|
"command": command,
|
|
"cliVersion": constants::CLI_VERSION,
|
|
"nodeVersion": Value::Null,
|
|
"platform": std::env::consts::OS,
|
|
"arch": std::env::consts::ARCH,
|
|
"sessionId": uuid::Uuid::new_v4().to_string(),
|
|
"metadata": metadata,
|
|
});
|
|
post_async(constants::TRACK_COMMAND_URL, body);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn path_falls_back_to_name_and_category_to_general() {
|
|
let (path, category) = download_path_category("web-fetch", &DownloadMeta::default());
|
|
assert_eq!(path, "web-fetch");
|
|
assert_eq!(category, "general");
|
|
}
|
|
|
|
#[test]
|
|
fn category_derives_from_slash_prefix() {
|
|
let (_, category) =
|
|
download_path_category("devtools/elasticsearch", &DownloadMeta::default());
|
|
assert_eq!(category, "devtools");
|
|
}
|
|
|
|
#[test]
|
|
fn target_directory_takes_priority_for_path() {
|
|
let meta = DownloadMeta {
|
|
target_directory: Some("../proj".into()),
|
|
path: Some("ignored".into()),
|
|
category: Some("custom".into()),
|
|
};
|
|
let (path, category) = download_path_category("a/b", &meta);
|
|
assert_eq!(path, "../proj");
|
|
assert_eq!(category, "custom");
|
|
}
|
|
|
|
#[test]
|
|
fn enabled_respects_opt_out_env() {
|
|
// Serialized via a single test to avoid env races across threads.
|
|
std::env::set_var("CCT_NO_TRACKING", "true");
|
|
assert!(!enabled());
|
|
std::env::set_var("CCT_NO_TRACKING", "1"); // documented `=1` value
|
|
assert!(!enabled());
|
|
std::env::remove_var("CCT_NO_TRACKING");
|
|
std::env::set_var("CI", "true");
|
|
assert!(!enabled());
|
|
std::env::remove_var("CI");
|
|
}
|
|
}
|