Files
davila7__claude-code-templates/cli-rust/src/tracking.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

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");
}
}