chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/target
/dist/cct
/dist/*.tgz
/npm/platforms
/npm/**/node_modules
# Cargo.lock IS committed (this is a binary crate) — do not ignore it.
+1884
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "claude-code-templates"
version = "0.1.0"
edition = "2021"
description = "Setup Claude Code configurations and install components (Rust port of the claude-code-templates CLI core)"
license = "MIT"
repository = "https://github.com/davila7/claude-code-templates"
rust-version = "1.74"
# The binary is named `cct` to match the primary alias of the Node CLI.
[[bin]]
name = "cct"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_yaml = "0.9"
dialoguer = "0.11"
indicatif = "0.17"
owo-colors = "4"
directories = "5"
anyhow = "1"
thiserror = "1"
uuid = { version = "1", features = ["v4"] }
open = "5"
base64 = "0.22"
pathdiff = "0.2"
[dev-dependencies]
tempfile = "3"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
# cargo-binstall metadata: resolve prebuilt binaries from GitHub Releases.
# Tags are `cli-rust-v<version>` (kept separate from the Node CLI's `vX.Y.Z`
# tags), and each release asset is `cct-<target>.tgz` containing `cct`.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/cli-rust-v{ version }/cct-{ target }{ archive-suffix }"
bin-dir = "cct{ binary-ext }"
pkg-fmt = "tgz"
+130
View File
@@ -0,0 +1,130 @@
# cct — Rust core for claude-code-templates
A Rust port of the **core** of the `claude-code-templates` CLI: component
installation (`agents`, `commands`, `mcps`, `settings`, `hooks`, `skills`),
verified at byte-for-byte parity with the Node.js CLI.
Everything else (dashboards, sandbox, global agents, stats, health-check,
interactive setup) is **delegated** to the existing Node CLI for now — see
[Delegation](#delegation).
## Install
Released as a standalone binary from GitHub Releases (tagged `cli-rust-v*`),
**separate from** the existing `claude-code-templates` npm package so nothing
about the current `npx` experience changes.
**Available now (v0.1.0 preview):**
| Channel | Command |
|---|---|
| Shell script | `curl -fsSL https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-rust/dist/install.sh \| sh` |
| cargo-binstall | `cargo binstall --git https://github.com/davila7/claude-code-templates claude-code-templates` |
| From source | `cargo install --path cli-rust` |
> The `--git` form reads the prebuilt-binary metadata straight from this repo,
> so no crates.io publish is required. (A plain `cargo binstall
> claude-code-templates` / `cargo install claude-code-templates` would need the
> crate published to crates.io — optional, later.)
**Planned (not wired up yet):**
| Channel | Command |
|---|---|
| Homebrew tap | `brew install davila7/tap/cct` |
| npm (new name) | `npx @davila7/cct` |
The npm path (when enabled) ships a tiny JS shim (`npm/cct/bin/cct.js`) that
execs the prebuilt binary for the current platform, pulled in as an
`optionalDependency` (`@davila7/cct-<os>-<arch>`). The existing
`claude-code-templates` npm package stays on Node until the binary is proven.
## Usage
```bash
cct --agent deep-research-team/research-synthesizer # install an agent
cct --mcp devtools/elasticsearch # merge into .mcp.json
cct --setting api/custom-headers --hook monitoring/desktop-notification-on-stop
cct --skill creative-design/algorithmic-art # recursive skill tree
cct --agent a,b --command c -d ./project -y # batch into a directory
```
Opt out of anonymous telemetry with `CCT_NO_TRACKING=1` (also disabled when
`CI=true`).
## Architecture
```
src/
main.rs dispatch: native install path vs Node delegation
cli.rs clap flag surface (mirrors the commander options)
constants.rs GitHub raw/API bases, tracking URLs, version
github.rs raw fetch + recursive contents-API tree (skills)
tracking.rs fire-and-forget analytics (3 endpoints, detached threads)
merge.rs .mcp.json / settings / hooks merge semantics (+ unit tests)
python_compat.rs Windows python3->python shim
commands/
install.rs installIndividual* + installMultipleComponents
delegate.rs forward argv to the Node CLI
util/{fs_ext,paths}.rs
```
Parity-critical details preserved: 2-space JSON with trailing newline,
`shift_remove` (not swap-remove) so key order matches JS `delete`,
`permissions.{allow,deny,ask}` Set-union, hook array concatenation, category
dropping in target filenames, `.py`/`.sh` sidecar downloads, skills via the
GitHub contents API.
## Delegation
Non-install flags forward verbatim to the Node CLI:
1. `CCT_NODE_BIN` — path to `cli-tool/bin/create-claude-config.js` (run with
`node`) or an executable. Used for local testing.
2. Fallback: `npx -y claude-code-templates@latest <args>`.
```bash
CCT_NODE_BIN=../cli-tool/bin/create-claude-config.js cct --list-agents
```
## Develop
```bash
cargo build # debug build
cargo test # unit tests (merge semantics)
cargo build --release # optimized binary at target/release/cct
```
### Parity check vs Node
```bash
RDIR=$(mktemp -d); NDIR=$(mktemp -d)
ARGS="--agent deep-research-team/research-synthesizer --mcp devtools/elasticsearch -y"
CCT_NO_TRACKING=1 target/release/cct $ARGS -d "$RDIR"
CCT_NO_TRACKING=1 node ../cli-tool/bin/create-claude-config.js $ARGS -d "$NDIR"
diff -r "$RDIR" "$NDIR" && echo "IDENTICAL"
```
## Release
### v0.1.0 preview (GitHub Releases + curl + cargo-binstall)
1. Merge this branch to `main` (so the workflow and `install.sh` exist there).
2. Tag and push: `git tag cli-rust-v0.1.0 && git push origin cli-rust-v0.1.0`.
`.github/workflows/build-rust-cli.yml` builds all 5 targets and uploads
`cct-<target>.tgz` to the GitHub Release (`contents: write` is granted).
3. Verify the channels:
```bash
curl -fsSL https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-rust/dist/install.sh | sh
cargo binstall --git https://github.com/davila7/claude-code-templates claude-code-templates
```
The tag must keep the `cli-rust-v` prefix — both the binstall metadata
(`Cargo.toml`) and `install.sh`'s tag filter depend on it.
### Later channels (when enabled)
- **Homebrew tap**: create `davila7/homebrew-tap`, drop in `dist/homebrew/cct.rb`,
fill the `sha256` per asset (`shasum -a 256 cct-<target>.tgz`).
- **npm (new name)**: `node npm/build-packages.mjs <version> <dist-dir>` (accepts
the release `.tgz`s), then publish each `npm/platforms/*` and `npm/cct` under a
new name (e.g. `@davila7/cct`) — leaves `claude-code-templates` untouched.
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Assemble per-platform npm packages from prebuilt Rust binaries.
//
// Usage:
// node build-packages.mjs <version> <dist-dir>
//
// <dist-dir> may contain EITHER the release tarballs `cct-<target>.tgz` (what
// build-rust-cli.yml uploads — each holds a single `cct`/`cct.exe`) OR the
// already-unpacked binaries `cct-<target>` / `cct-<target>.exe`. Produces
// `cli-rust/npm/platforms/<pkg>/` directories ready to `npm publish`, and
// rewrites the version in the main wrapper package + its optionalDependencies.
import {
mkdirSync,
mkdtempSync,
copyFileSync,
writeFileSync,
readFileSync,
chmodSync,
existsSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Resolve the source binary for a target: prefer the release tarball
// (`cct-<target>.tgz`), extracting it to a temp dir; otherwise fall back to an
// unpacked `cct-<target>` binary in the dist dir.
function resolveSourceBinary(distDir, rustTarget, binName) {
const tgz = join(distDir, `cct-${rustTarget}.tgz`);
if (existsSync(tgz)) {
const tmp = mkdtempSync(join(tmpdir(), 'cct-pkg-'));
execFileSync('tar', ['-xzf', tgz, '-C', tmp]);
return join(tmp, binName);
}
const isWin = binName.endsWith('.exe');
return join(distDir, `cct-${rustTarget}${isWin ? '.exe' : ''}`);
}
// Rust target triple -> { pkg, os, cpu, rustTarget }
const TARGETS = [
{ pkg: '@davila7/cct-darwin-arm64', os: 'darwin', cpu: 'arm64', rustTarget: 'aarch64-apple-darwin' },
{ pkg: '@davila7/cct-darwin-x64', os: 'darwin', cpu: 'x64', rustTarget: 'x86_64-apple-darwin' },
{ pkg: '@davila7/cct-linux-x64', os: 'linux', cpu: 'x64', rustTarget: 'x86_64-unknown-linux-gnu' },
{ pkg: '@davila7/cct-linux-arm64', os: 'linux', cpu: 'arm64', rustTarget: 'aarch64-unknown-linux-gnu' },
{ pkg: '@davila7/cct-win32-x64', os: 'win32', cpu: 'x64', rustTarget: 'x86_64-pc-windows-msvc' },
];
const version = process.argv[2];
const distDir = process.argv[3];
if (!version || !distDir) {
console.error('Usage: node build-packages.mjs <version> <dist-dir>');
process.exit(1);
}
const platformsRoot = join(__dirname, 'platforms');
for (const t of TARGETS) {
const isWin = t.os === 'win32';
const binName = isWin ? 'cct.exe' : 'cct';
const srcBin = resolveSourceBinary(distDir, t.rustTarget, binName);
const pkgDir = join(platformsRoot, t.pkg.replace('/', '__'));
const binDir = join(pkgDir, 'bin');
mkdirSync(binDir, { recursive: true });
const destBin = join(binDir, binName);
copyFileSync(srcBin, destBin);
if (!isWin) chmodSync(destBin, 0o755);
const pkgJson = {
name: t.pkg,
version,
description: `Prebuilt cct binary for ${t.os}/${t.cpu}`,
os: [t.os],
cpu: [t.cpu],
files: [`bin/${binName}`],
license: 'MIT',
repository: {
type: 'git',
url: 'git+https://github.com/davila7/claude-code-templates.git',
},
};
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify(pkgJson, null, 2) + '\n');
console.log(`✓ assembled ${t.pkg} (${t.rustTarget})`);
}
// Sync version into the main wrapper package + optionalDependencies.
const mainPkgPath = join(__dirname, 'cct', 'package.json');
const mainPkg = JSON.parse(readFileSync(mainPkgPath, 'utf8'));
mainPkg.version = version;
for (const t of TARGETS) mainPkg.optionalDependencies[t.pkg] = version;
writeFileSync(mainPkgPath, JSON.stringify(mainPkg, null, 2) + '\n');
console.log(`✓ synced version ${version} into wrapper package`);
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
'use strict';
// Thin launcher shim (esbuild/Biome pattern). Resolves the prebuilt Rust
// binary for the current platform from an optional dependency package and
// execs it, forwarding all args and stdio. The user keeps running
// `npx claude-code-templates@latest ...` exactly as before.
const { spawnSync } = require('child_process');
const PKG_BY_KEY = {
'darwin-arm64': '@davila7/cct-darwin-arm64',
'darwin-x64': '@davila7/cct-darwin-x64',
'linux-x64': '@davila7/cct-linux-x64',
'linux-arm64': '@davila7/cct-linux-arm64',
'win32-x64': '@davila7/cct-win32-x64',
};
function resolveBinary() {
const key = `${process.platform}-${process.arch}`;
const pkg = PKG_BY_KEY[key];
if (!pkg) {
throw new Error(
`Unsupported platform "${key}". Supported: ${Object.keys(PKG_BY_KEY).join(', ')}`
);
}
const binName = process.platform === 'win32' ? 'cct.exe' : 'cct';
try {
return require.resolve(`${pkg}/bin/${binName}`);
} catch (_) {
throw new Error(
`The platform package "${pkg}" is not installed.\n` +
`Try reinstalling: npm install -g claude-code-templates\n` +
`or download a binary from https://github.com/davila7/claude-code-templates/releases`
);
}
}
try {
const bin = resolveBinary();
const result = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
if (result.error) throw result.error;
process.exit(result.status === null ? 1 : result.status);
} catch (err) {
console.error(`cct: ${err.message}`);
process.exit(1);
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "claude-code-templates",
"version": "0.1.0",
"description": "Setup Claude Code configurations and install components (Rust core)",
"bin": {
"cct": "bin/cct.js",
"claude-code-templates": "bin/cct.js",
"claude-config": "bin/cct.js",
"claude-init": "bin/cct.js",
"claude-setup": "bin/cct.js",
"create-claude-config": "bin/cct.js"
},
"files": [
"bin/cct.js"
],
"engines": {
"node": ">=14.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"repository": {
"type": "git",
"url": "git+https://github.com/davila7/claude-code-templates.git"
},
"license": "MIT",
"optionalDependencies": {
"@davila7/cct-darwin-arm64": "0.1.0",
"@davila7/cct-darwin-x64": "0.1.0",
"@davila7/cct-linux-x64": "0.1.0",
"@davila7/cct-linux-arm64": "0.1.0",
"@davila7/cct-win32-x64": "0.1.0"
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Clap definition mirroring the commander option surface of the Node CLI.
//!
//! Flags are split into a natively-handled CORE path (component installation +
//! workflow-with-components) and a DASHBOARD/other path that delegates to Node.
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "cct",
version,
about = "Setup Claude Code configurations and install components"
)]
pub struct Cli {
// --- Project setup / template selection ---
#[arg(short = 'l', long)]
pub language: Option<String>,
#[arg(short = 'f', long)]
pub framework: Option<String>,
#[arg(short = 't', long)]
pub template: Option<String>,
#[arg(short = 'd', long)]
pub directory: Option<String>,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long = "dry-run")]
pub dry_run: bool,
// --- Component installation (CORE, native) ---
#[arg(long)]
pub agent: Option<String>,
#[arg(long)]
pub command: Option<String>,
#[arg(long)]
pub mcp: Option<String>,
#[arg(long)]
pub setting: Option<String>,
#[arg(long)]
pub hook: Option<String>,
#[arg(long)]
pub skill: Option<String>,
#[arg(long)]
pub workflow: Option<String>,
#[arg(long)]
pub prompt: Option<String>,
// --- Global agent management (delegated) ---
#[arg(long = "create-agent")]
pub create_agent: Option<String>,
#[arg(long = "list-agents")]
pub list_agents: bool,
#[arg(long = "remove-agent")]
pub remove_agent: Option<String>,
#[arg(long = "update-agent")]
pub update_agent: Option<String>,
// --- Analysis (delegated) ---
#[arg(long = "command-stats", visible_alias = "commands-stats")]
pub command_stats: bool,
#[arg(long = "hook-stats", visible_alias = "hooks-stats")]
pub hook_stats: bool,
#[arg(long = "mcp-stats", visible_alias = "mcps-stats")]
pub mcp_stats: bool,
#[arg(
long = "health-check",
visible_alias = "health",
visible_alias = "check",
visible_alias = "verify"
)]
pub health_check: bool,
// --- Dashboards & misc (delegated) ---
#[arg(long)]
pub analytics: bool,
#[arg(long)]
pub chats: bool,
#[arg(long)]
pub agents: bool,
#[arg(long = "chats-mobile")]
pub chats_mobile: bool,
#[arg(long)]
pub plugins: bool,
#[arg(long = "skills-manager")]
pub skills_manager: bool,
#[arg(long)]
pub teams: bool,
#[arg(long = "2025")]
pub year_2025: bool,
#[arg(long)]
pub tunnel: bool,
#[arg(long)]
pub studio: bool,
#[arg(long)]
pub sandbox: Option<String>,
#[arg(long = "e2b-api-key")]
pub e2b_api_key: Option<String>,
#[arg(long = "anthropic-api-key")]
pub anthropic_api_key: Option<String>,
#[arg(long = "clone-session")]
pub clone_session: Option<String>,
#[arg(long)]
pub verbose: bool,
}
impl Cli {
/// True when any component-install flag is present (the native path).
pub fn has_install_flags(&self) -> bool {
self.agent.is_some()
|| self.command.is_some()
|| self.mcp.is_some()
|| self.setting.is_some()
|| self.hook.is_some()
|| self.skill.is_some()
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Delegation to the existing Node.js CLI for features not yet ported natively
//! (dashboards, sandbox, global agents, stats, health-check, interactive setup).
//!
//! Resolution order for the Node entry point:
//! 1. `CCT_NODE_BIN` env var — path to `create-claude-config.js` (run with
//! `node`) or an executable to invoke directly. Used for local testing.
//! 2. Fallback: `npx -y claude-code-templates@latest <args>`.
//!
//! The original process args (everything after the binary name) are forwarded
//! verbatim with inherited stdio, so the delegated command behaves identically.
use anyhow::{anyhow, Result};
use std::process::Command;
pub fn delegate_to_node(forwarded_args: &[String]) -> Result<i32> {
let mut command = build_command(forwarded_args)?;
let status = command
.status()
.map_err(|e| anyhow!("failed to launch Node CLI: {e}. Is Node.js installed?"))?;
// Preserve the child's termination semantics: a normal exit code, or for a
// signal-killed child on Unix, the shell convention 128 + signal.
let code = status.code().unwrap_or_else(|| {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status.signal().map(|s| 128 + s).unwrap_or(1)
}
#[cfg(not(unix))]
{
1
}
});
Ok(code)
}
fn build_command(forwarded_args: &[String]) -> Result<Command> {
if let Ok(node_bin) = std::env::var("CCT_NODE_BIN") {
if node_bin.ends_with(".js") {
let mut c = Command::new("node");
c.arg(&node_bin).args(forwarded_args);
Ok(c)
} else {
let mut c = Command::new(node_bin);
c.args(forwarded_args);
Ok(c)
}
} else {
let mut c = Command::new("npx");
c.arg("-y")
.arg("claude-code-templates@latest")
.args(forwarded_args);
Ok(c)
}
}
+944
View File
@@ -0,0 +1,944 @@
//! Native port of the component installation path (`installIndividual*` and
//! `installMultipleComponents` from `cli-tool/src/index.js`).
use crate::constants::raw_components_base;
use crate::github::{self, Fetched};
use crate::merge;
use crate::python_compat::replace_python_commands;
use crate::tracking::{self, DownloadMeta, Outcome, OutcomeMeta};
use crate::util::{fs_ext, paths};
use anyhow::Result;
use owo_colors::OwoColorize;
use serde_json::Value;
use std::io::IsTerminal;
use std::path::Path;
use std::time::Instant;
/// Per-install options threaded through the installers, mirroring the Node
/// `options` object's relevant fields.
#[derive(Clone, Default)]
pub struct InstallOptions {
pub silent: bool,
pub shared_locations: Option<Vec<String>>,
pub batch_id: Option<String>,
}
fn elapsed_ms(start: Instant) -> u128 {
start.elapsed().as_millis()
}
fn target_directory_meta(target_dir: &Path) -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
paths::relative_path(&cwd, target_dir)
}
// ---------------------------------------------------------------------------
// Agent
// ---------------------------------------------------------------------------
pub fn install_agent(name: &str, target_dir: &Path, opts: &InstallOptions) -> bool {
println!("{}", format!("🤖 Installing agent: {name}").blue());
let start = Instant::now();
let url = format!("{}/agents/{name}.md", raw_components_base());
match github::fetch_raw(&url) {
Ok(Fetched::Ok(content)) => {
let file_name = last_segment(name);
let dir = target_dir.join(".claude").join("agents");
let target_file = dir.join(format!("{file_name}.md"));
if let Err(e) = fs_ext::write_file(&target_file, &content, false) {
println!("{}", format!("❌ Error installing agent: {e}").red());
track_fail(
"agent",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
if !opts.silent {
println!(
"{}",
format!("✅ Agent \"{name}\" installed successfully!").green()
);
}
tracking::track_download(
"agent",
name,
&DownloadMeta {
target_directory: Some(target_directory_meta(target_dir)),
..Default::default()
},
);
track_success("agent", name, start, opts);
true
}
Ok(Fetched::NotFound) => {
println!("{}", format!("❌ Agent \"{name}\" not found").red());
track_fail("agent", name, "not_found", None, start, opts);
false
}
other => network_error("agent", name, other, start, opts),
}
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
pub fn install_command(name: &str, target_dir: &Path, opts: &InstallOptions) -> bool {
println!("{}", format!("⚡ Installing command: {name}").blue());
let start = Instant::now();
let url = format!("{}/commands/{name}.md", raw_components_base());
match github::fetch_raw(&url) {
Ok(Fetched::Ok(content)) => {
let file_name = last_segment(name);
let target_file = target_dir
.join(".claude")
.join("commands")
.join(format!("{file_name}.md"));
if let Err(e) = fs_ext::write_file(&target_file, &content, false) {
println!("{}", format!("❌ Error installing command: {e}").red());
track_fail(
"command",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
if !opts.silent {
println!(
"{}",
format!("✅ Command \"{name}\" installed successfully!").green()
);
}
tracking::track_download(
"command",
name,
&DownloadMeta {
target_directory: Some(target_directory_meta(target_dir)),
..Default::default()
},
);
track_success("command", name, start, opts);
true
}
Ok(Fetched::NotFound) => {
println!("{}", format!("❌ Command \"{name}\" not found").red());
track_fail("command", name, "not_found", None, start, opts);
false
}
other => network_error("command", name, other, start, opts),
}
}
// ---------------------------------------------------------------------------
// MCP
// ---------------------------------------------------------------------------
pub fn install_mcp(name: &str, target_dir: &Path, opts: &InstallOptions) -> bool {
println!("{}", format!("🔌 Installing MCP: {name}").blue());
let start = Instant::now();
let url = format!("{}/mcps/{name}.json", raw_components_base());
let text = match github::fetch_raw(&url) {
Ok(Fetched::Ok(t)) => t,
Ok(Fetched::NotFound) => {
println!("{}", format!("❌ MCP \"{name}\" not found").red());
track_fail("mcp", name, "not_found", None, start, opts);
return false;
}
other => return network_error("mcp", name, other, start, opts),
};
let mut mcp_config: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
println!("{}", format!("❌ Error installing MCP: {e}").red());
track_fail(
"mcp",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
};
merge::strip_mcp_descriptions(&mut mcp_config);
let target_file = target_dir.join(".mcp.json");
let existing = if target_file.exists() {
// Fail safe on a corrupt/unreadable existing file rather than silently
// overwriting it with just the new server (matches Node, which throws).
match fs_ext::read_json(&target_file) {
Ok(v) => v,
Err(e) => {
println!(
"{}",
format!(
"❌ Error installing MCP: cannot read existing {}: {e}",
target_file.display()
)
.red()
);
track_fail("mcp", name, "read_error", Some(e.to_string()), start, opts);
return false;
}
}
} else {
Value::Object(Default::default())
};
let merged = merge::merge_mcp(&existing, &mcp_config);
if let Err(e) = fs_ext::write_json_pretty(&target_file, &merged) {
println!("{}", format!("❌ Error installing MCP: {e}").red());
track_fail(
"mcp",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
if !opts.silent {
println!(
"{}",
format!("✅ MCP \"{name}\" installed successfully!").green()
);
}
tracking::track_download("mcp", name, &DownloadMeta::default());
track_success("mcp", name, start, opts);
true
}
// ---------------------------------------------------------------------------
// Setting
// ---------------------------------------------------------------------------
/// Returns the number of locations successfully installed into.
pub fn install_setting(name: &str, target_dir: &Path, opts: &InstallOptions) -> usize {
println!("{}", format!("⚙️ Installing setting: {name}").blue());
let component_type = "setting";
let start = Instant::now();
let url = format!("{}/settings/{name}.json", raw_components_base());
let text = match github::fetch_raw(&url) {
Ok(Fetched::Ok(t)) => t,
Ok(Fetched::NotFound) => {
println!("{}", format!("❌ Setting \"{name}\" not found").red());
track_fail("setting", name, "not_found", None, start, opts);
return 0;
}
other => {
network_error("setting", name, other, start, opts);
return 0;
}
};
let mut setting_config: Value = match serde_json::from_str(&text) {
Ok(v) => replace_python_commands(v),
Err(e) => {
println!("{}", format!("❌ Error installing setting: {e}").red());
track_fail(
"setting",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return 0;
}
};
// Collect additional files: statusline python sidecar + config `files` map.
let mut additional: Vec<(String, String, bool)> = Vec::new();
if name.contains("statusline/") {
let py_url = url.replace(".json", ".py");
if let Some(py) = github::fetch_raw_optional(&py_url) {
let base = name.split('/').nth(1).unwrap_or(name);
additional.push((format!(".claude/scripts/{base}.py"), py, true));
}
}
if let Some(files) = setting_config.get("files").and_then(Value::as_object) {
for (path, cfg) in files.iter() {
let content = cfg
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let exe = cfg
.get("executable")
.and_then(Value::as_bool)
.unwrap_or(false);
additional.push((path.clone(), content, exe));
}
}
// Strip description + files before merging (shift_remove preserves order).
if let Some(obj) = setting_config.as_object_mut() {
obj.shift_remove("description");
obj.shift_remove("files");
}
let locations = resolve_install_locations(opts, "setting");
let mut successful = 0usize;
for location in &locations {
let resolved = paths::resolve_location(location, target_dir);
let target_file = &resolved.settings_file;
let existing = if target_file.exists() {
// Abort the whole install on a corrupt/unreadable existing file
// (matches Node: readJson throws out of the loop and fails the
// component) rather than skipping the location and possibly
// reporting overall success.
match fs_ext::read_json(target_file) {
Ok(v) => v,
Err(e) => {
println!(
"{}",
format!(
"❌ Error installing {component_type}: cannot read existing {}: {e}",
target_file.display()
)
.red()
);
track_fail(
component_type,
name,
"read_error",
Some(e.to_string()),
start,
opts,
);
return 0;
}
}
} else {
Value::Object(Default::default())
};
// Conflict detection (env vars + differing non-permissions/env/hooks keys).
if has_setting_conflicts(&existing, &setting_config) && !confirm_overwrite(name, location) {
continue;
}
let merged = merge::merge_settings(&existing, &setting_config);
if let Err(e) = fs_ext::write_json_pretty(target_file, &merged) {
println!(
"{}",
format!("❌ Failed to write {}: {e}", target_file.display()).red()
);
continue;
}
write_additional_files(&additional, &resolved.current_target_dir);
tracking::track_download("setting", name, &DownloadMeta::default());
successful += 1;
}
if !opts.silent && successful > 0 {
println!(
"{}",
format!("🎉 Setting \"{name}\" installed in {successful} location(s)!").green()
);
}
track_outcome_count("setting", name, successful, start, opts);
successful
}
fn has_setting_conflicts(existing: &Value, incoming: &Value) -> bool {
// Conflicting env vars.
if let (Some(e_env), Some(i_env)) = (
existing.get("env").and_then(Value::as_object),
incoming.get("env").and_then(Value::as_object),
) {
for (k, v) in i_env.iter() {
if let Some(existing_v) = e_env.get(k) {
if existing_v != v {
return true;
}
}
}
}
// Conflicting top-level keys (excluding permissions/env/hooks).
if let Some(incoming_obj) = incoming.as_object() {
for (k, v) in incoming_obj.iter() {
if k == "permissions" || k == "env" || k == "hooks" {
continue;
}
if let Some(existing_v) = existing.get(k) {
if existing_v != v {
return true;
}
}
}
}
false
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/// Returns the number of locations successfully installed into.
pub fn install_hook(name: &str, target_dir: &Path, opts: &InstallOptions) -> usize {
println!("{}", format!("🪝 Installing hook: {name}").blue());
let component_type = "hook";
let start = Instant::now();
let url = format!("{}/hooks/{name}.json", raw_components_base());
let text = match github::fetch_raw(&url) {
Ok(Fetched::Ok(t)) => t,
Ok(Fetched::NotFound) => {
println!("{}", format!("❌ Hook \"{name}\" not found").red());
track_fail("hook", name, "not_found", None, start, opts);
return 0;
}
other => {
network_error("hook", name, other, start, opts);
return 0;
}
};
let mut hook_config: Value = match serde_json::from_str(&text) {
Ok(v) => replace_python_commands(v),
Err(e) => {
println!("{}", format!("❌ Error installing hook: {e}").red());
track_fail(
"hook",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return 0;
}
};
// Optional `.py` and `.sh` sidecars for any hook.
let base = last_segment(name);
let mut additional: Vec<(String, String, bool)> = Vec::new();
if let Some(py) = github::fetch_raw_optional(&url.replace(".json", ".py")) {
additional.push((format!(".claude/hooks/{base}.py"), py, true));
}
if let Some(sh) = github::fetch_raw_optional(&url.replace(".json", ".sh")) {
additional.push((format!(".claude/hooks/{base}.sh"), sh, true));
}
if let Some(obj) = hook_config.as_object_mut() {
obj.shift_remove("description"); // preserve key order like JS `delete`
}
let locations = resolve_install_locations(opts, "hook");
let mut successful = 0usize;
for location in &locations {
let resolved = paths::resolve_location(location, target_dir);
let target_file = &resolved.settings_file;
let existing = if target_file.exists() {
// Abort the whole install on a corrupt/unreadable existing file
// (matches Node: readJson throws out of the loop and fails the
// component) rather than skipping the location and possibly
// reporting overall success.
match fs_ext::read_json(target_file) {
Ok(v) => v,
Err(e) => {
println!(
"{}",
format!(
"❌ Error installing {component_type}: cannot read existing {}: {e}",
target_file.display()
)
.red()
);
track_fail(
component_type,
name,
"read_error",
Some(e.to_string()),
start,
opts,
);
return 0;
}
}
} else {
Value::Object(Default::default())
};
let merged = merge::merge_hooks(&existing, &hook_config);
if let Err(e) = fs_ext::write_json_pretty(target_file, &merged) {
println!(
"{}",
format!("❌ Failed to write {}: {e}", target_file.display()).red()
);
continue;
}
write_additional_files(&additional, &resolved.current_target_dir);
tracking::track_download("hook", name, &DownloadMeta::default());
successful += 1;
}
if !opts.silent && successful > 0 {
println!(
"{}",
format!("🎉 Hook \"{name}\" installed in {successful} location(s)!").green()
);
}
track_outcome_count("hook", name, successful, start, opts);
successful
}
// ---------------------------------------------------------------------------
// Skill
// ---------------------------------------------------------------------------
pub fn install_skill(name: &str, target_dir: &Path, opts: &InstallOptions) -> bool {
println!("{}", format!("💡 Installing skill: {name}").blue());
let start = Instant::now();
let base = last_segment(name);
let api_url = format!("{}/skills/{name}", crate::constants::api_components_base());
let files = match github::download_skill_tree(&api_url, base) {
Ok(Some(files)) => files,
Ok(None) => {
println!("{}", format!("❌ Skill \"{name}\" not found").red());
track_fail("skill", name, "not_found", None, start, opts);
return false;
}
Err(e) => {
println!("{}", format!("❌ Error installing skill: {e}").red());
track_fail(
"skill",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
};
// SKILL.md is required.
let skill_md = format!(".claude/skills/{base}/SKILL.md");
if !files.iter().any(|f| f.target_rel_path == skill_md) {
println!("{}", "❌ SKILL.md not found in skill directory".red());
track_fail(
"skill",
name,
"validation_error",
Some("SKILL.md not found".into()),
start,
opts,
);
return false;
}
for f in &files {
let full = target_dir.join(&f.target_rel_path);
if let Err(e) = fs_ext::write_file(&full, &f.content, f.executable) {
println!(
"{}",
format!("❌ Failed to write {}: {e}", full.display()).red()
);
track_fail(
"skill",
name,
"network_error",
Some(e.to_string()),
start,
opts,
);
return false;
}
}
if !opts.silent {
println!(
"{}",
format!("✅ Skill \"{name}\" installed successfully!").green()
);
println!(
"{}",
format!("📄 Total files downloaded: {}", files.len()).cyan()
);
}
tracking::track_download(
"skill",
name,
&DownloadMeta {
target_directory: Some(target_directory_meta(target_dir)),
..Default::default()
},
);
track_success("skill", name, start, opts);
true
}
// ---------------------------------------------------------------------------
// Multi-component orchestration
// ---------------------------------------------------------------------------
/// Parsed component lists from the comma-separated CLI flags.
#[derive(Default)]
pub struct MultiSpec {
pub agents: Vec<String>,
pub commands: Vec<String>,
pub mcps: Vec<String>,
pub settings: Vec<String>,
pub hooks: Vec<String>,
pub skills: Vec<String>,
/// base64-encoded workflow YAML, when `--workflow` accompanies components.
pub workflow_yaml: Option<String>,
pub yes: bool,
}
pub fn parse_csv(input: Option<&String>) -> Vec<String> {
input
.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn install_multiple(spec: &MultiSpec, target_dir: &Path) -> Result<()> {
println!("{}", "🔧 Installing multiple components...".blue());
let batch_id = short_id();
let total = spec.agents.len()
+ spec.commands.len()
+ spec.mcps.len()
+ spec.settings.len()
+ spec.hooks.len()
+ spec.skills.len();
if total == 0 {
println!("{}", "⚠️ No components specified to install.".yellow());
return Ok(());
}
// Ask once for shared install locations when settings/hooks are present.
let has_settings_or_hooks = !spec.settings.is_empty() || !spec.hooks.is_empty();
let shared = if has_settings_or_hooks && !spec.yes {
Some(prompt_locations("configuration components"))
} else {
Some(vec!["local".to_string()])
};
let mut installed = 0usize;
let base_opts = InstallOptions {
silent: true,
shared_locations: shared.clone(),
batch_id: Some(batch_id.clone()),
};
for a in &spec.agents {
if install_agent(a, target_dir, &base_opts) {
installed += 1;
}
}
for c in &spec.commands {
if install_command(c, target_dir, &base_opts) {
installed += 1;
}
}
for m in &spec.mcps {
if install_mcp(m, target_dir, &base_opts) {
installed += 1;
}
}
for s in &spec.settings {
if install_setting(s, target_dir, &base_opts) > 0 {
installed += 1;
}
}
for h in &spec.hooks {
if install_hook(h, target_dir, &base_opts) > 0 {
installed += 1;
}
}
for s in &spec.skills {
if install_skill(s, target_dir, &base_opts) {
installed += 1;
}
}
if let Some(b64) = &spec.workflow_yaml {
save_workflow_yaml(b64, target_dir);
}
if installed == total {
println!(
"{}",
format!("\n✅ Successfully installed {installed} components!").green()
);
} else if installed > 0 {
println!(
"{}",
format!("\n⚠️ Installed {installed} of {total} components.").yellow()
);
} else {
println!(
"{}",
"\n❌ No components were installed successfully.".red()
);
}
Ok(())
}
fn save_workflow_yaml(b64: &str, target_dir: &Path) {
use base64::Engine;
let decoded = match base64::engine::general_purpose::STANDARD.decode(b64) {
Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
Err(e) => {
println!("{}", format!("❌ Error processing YAML: {e}").red());
return;
}
};
// Extract `name:` and slugify, matching the Node regex behavior.
let mut workflow_name = "custom-workflow".to_string();
for line in decoded.lines() {
if let Some(rest) = line.trim_start().strip_prefix("name:") {
let val = rest.trim().trim_matches(|c| c == '"' || c == '\'');
if !val.is_empty() {
workflow_name = slugify(val);
break;
}
}
}
let file = target_dir
.join(".claude")
.join("workflows")
.join(format!("{workflow_name}.yaml"));
if let Err(e) = fs_ext::write_file(&file, &decoded, false) {
println!("{}", format!("❌ Error saving workflow: {e}").red());
} else {
println!(
"{}",
format!("✅ Workflow YAML saved: {}", file.display()).green()
);
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
fn last_segment(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn slugify(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn short_id() -> String {
// Mirrors Math.random().toString(36).substring(2,15) — a short opaque id.
uuid::Uuid::new_v4().simple().to_string()[..13].to_string()
}
/// Resolve install locations: shared (batch) → those; silent without shared →
/// default local; interactive → prompt.
fn resolve_install_locations(opts: &InstallOptions, what: &str) -> Vec<String> {
if let Some(shared) = &opts.shared_locations {
return shared.clone();
}
if opts.silent {
return vec!["local".to_string()];
}
prompt_locations(what)
}
/// Multi-select install-location prompt. Falls back to `["local"]` when stdin
/// is not a TTY (non-interactive / piped).
fn prompt_locations(what: &str) -> Vec<String> {
if !std::io::stdin().is_terminal() {
return vec!["local".to_string()];
}
use dialoguer::MultiSelect;
let items = [
"🏠 User settings (~/.claude/settings.json) — all projects",
"📁 Project settings (.claude/settings.json) — shared with team",
"⚙️ Local settings (.claude/settings.local.json) — personal",
"🏢 Enterprise managed settings — system-wide (requires admin)",
];
let values = ["user", "project", "local", "enterprise"];
let selection = MultiSelect::new()
.with_prompt(format!("Where would you like to install the {what}?"))
.items(&items)
.defaults(&[false, false, true, false])
.interact()
.unwrap_or_else(|_| vec![2]);
let chosen: Vec<String> = selection.iter().map(|&i| values[i].to_string()).collect();
if chosen.is_empty() {
vec!["local".to_string()]
} else {
chosen
}
}
fn confirm_overwrite(name: &str, location: &str) -> bool {
if !std::io::stdin().is_terminal() {
// inquirer default is `false` → do not overwrite.
return false;
}
use dialoguer::Confirm;
println!(
"{}",
format!("⚠️ Conflicts detected installing \"{name}\" in {location}.").yellow()
);
Confirm::new()
.with_prompt(format!(
"Overwrite the existing configuration in {location}?"
))
.default(false)
.interact()
.unwrap_or(false)
}
fn write_additional_files(files: &[(String, String, bool)], current_target_dir: &Path) {
for (path, content, executable) in files {
let resolved = paths::resolve_additional_file(path, current_target_dir);
if let Err(e) = fs_ext::write_file(&resolved, content, *executable) {
println!("{}", format!("❌ Failed to install file {path}: {e}").red());
}
}
}
// --- tracking shims -------------------------------------------------------
fn track_success(ctype: &str, name: &str, start: Instant, opts: &InstallOptions) {
tracking::track_outcome(
ctype,
name,
Outcome::Success,
&OutcomeMeta {
duration_ms: Some(elapsed_ms(start)),
batch_id: opts.batch_id.clone(),
..Default::default()
},
);
}
fn track_fail(
ctype: &str,
name: &str,
error_type: &str,
error_message: Option<String>,
start: Instant,
opts: &InstallOptions,
) {
tracking::track_outcome(
ctype,
name,
Outcome::Failure,
&OutcomeMeta {
error_type: Some(error_type.to_string()),
error_message,
duration_ms: Some(elapsed_ms(start)),
batch_id: opts.batch_id.clone(),
},
);
}
fn track_outcome_count(
ctype: &str,
name: &str,
count: usize,
start: Instant,
opts: &InstallOptions,
) {
let outcome = if count > 0 {
Outcome::Success
} else {
Outcome::Failure
};
tracking::track_outcome(
ctype,
name,
outcome,
&OutcomeMeta {
duration_ms: Some(elapsed_ms(start)),
batch_id: opts.batch_id.clone(),
..Default::default()
},
);
}
/// Print a generic network error for non-404 statuses / transport errors.
fn network_error(
ctype: &str,
name: &str,
fetched: Result<Fetched>,
start: Instant,
opts: &InstallOptions,
) -> bool {
let msg = match fetched {
Ok(Fetched::Status(s)) => format!("HTTP {s}"),
Err(e) => e.to_string(),
_ => "unknown error".to_string(),
};
println!("{}", format!("❌ Error installing {ctype}: {msg}").red());
track_fail(ctype, name, "network_error", Some(msg), start, opts);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn last_segment_drops_category_prefix() {
assert_eq!(
last_segment("deep-research-team/research-synthesizer"),
"research-synthesizer"
);
assert_eq!(last_segment("a/b/c"), "c");
assert_eq!(last_segment("plain-name"), "plain-name");
}
#[test]
fn slugify_lowercases_and_replaces_non_alnum() {
assert_eq!(slugify("My Workflow #1"), "my_workflow__1");
assert_eq!(slugify("already-ok"), "already_ok");
}
#[test]
fn parse_csv_trims_and_filters_empties() {
let s = " a , b ,, c ".to_string();
assert_eq!(parse_csv(Some(&s)), vec!["a", "b", "c"]);
assert!(parse_csv(None).is_empty());
assert!(parse_csv(Some(&"".to_string())).is_empty());
}
#[test]
fn short_id_has_expected_length() {
assert_eq!(short_id().len(), 13);
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Command implementations: native installers + Node delegation.
pub mod delegate;
pub mod install;
+60
View File
@@ -0,0 +1,60 @@
//! Centralized constants: GitHub endpoints, tracking URLs, repo coordinates.
//!
//! Mirrors the hard-coded URLs used across `cli-tool/src/index.js` and
//! `cli-tool/src/tracking-service.js` so the Rust port hits the exact same
//! sources and analytics endpoints.
/// Repo owner/name and default branch used to build raw + API URLs.
pub const REPO: &str = "davila7/claude-code-templates";
pub const BRANCH: &str = "main";
/// Base for raw component downloads:
/// `https://raw.githubusercontent.com/<repo>/<branch>/cli-tool/components`
pub fn raw_components_base() -> String {
format!("https://raw.githubusercontent.com/{REPO}/{BRANCH}/cli-tool/components")
}
/// Base for the GitHub contents API (used by skills, recursive tree download):
/// `https://api.github.com/repos/<repo>/contents/cli-tool/components`
pub fn api_components_base() -> String {
format!("https://api.github.com/repos/{REPO}/contents/cli-tool/components")
}
// Tracking endpoints (Vercel — www.aitmpl.com). Fire-and-forget only.
pub const TRACK_DOWNLOAD_URL: &str = "https://www.aitmpl.com/api/track-download-supabase";
pub const TRACK_COMMAND_URL: &str = "https://www.aitmpl.com/api/track-command-usage";
pub const TRACK_OUTCOME_URL: &str = "https://www.aitmpl.com/api/track-installation-outcome";
/// CLI version reported to tracking + User-Agent.
pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
/// User-Agent string used for tracking and the GitHub contents API.
pub fn user_agent() -> String {
format!("claude-code-templates/{CLI_VERSION}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_base_points_at_main_components() {
assert_eq!(
raw_components_base(),
"https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-tool/components"
);
}
#[test]
fn api_base_points_at_contents() {
assert_eq!(
api_components_base(),
"https://api.github.com/repos/davila7/claude-code-templates/contents/cli-tool/components"
);
}
#[test]
fn user_agent_carries_version() {
assert!(user_agent().starts_with("claude-code-templates/"));
}
}
+134
View File
@@ -0,0 +1,134 @@
//! 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)
}
+141
View File
@@ -0,0 +1,141 @@
//! `cct` — Rust port of the claude-code-templates CLI core.
//!
//! Native path: component installation (`--agent/--command/--mcp/--setting/
//! --hook/--skill`, plus `--workflow` YAML when combined with components).
//! Everything else (dashboards, sandbox, global agents, stats, health-check,
//! interactive setup) is delegated verbatim to the existing Node CLI.
mod cli;
mod commands;
mod constants;
mod github;
mod merge;
mod python_compat;
mod tracking;
mod util;
use clap::Parser;
use cli::Cli;
use commands::install::{self, MultiSpec};
use owo_colors::OwoColorize;
use std::path::PathBuf;
use std::process::Command;
fn main() {
// Capture original args (minus the binary name) for verbatim delegation.
let forwarded: Vec<String> = std::env::args().skip(1).collect();
let cli = Cli::parse();
let code = if cli.has_install_flags() {
run_install(&cli)
} else {
// Delegate to the Node CLI for all non-install features.
match commands::delegate::delegate_to_node(&forwarded) {
Ok(code) => code,
Err(e) => {
eprintln!("{} {e}", "Error:".red());
1
}
}
};
std::process::exit(code);
}
fn run_install(cli: &Cli) -> i32 {
let target_dir = cli
.directory
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let spec = MultiSpec {
agents: install::parse_csv(cli.agent.as_ref()),
commands: install::parse_csv(cli.command.as_ref()),
mcps: install::parse_csv(cli.mcp.as_ref()),
settings: install::parse_csv(cli.setting.as_ref()),
hooks: install::parse_csv(cli.hook.as_ref()),
skills: install::parse_csv(cli.skill.as_ref()),
// `--workflow` alongside components is treated as base64 YAML.
workflow_yaml: cli.workflow.clone(),
yes: cli.yes,
};
// --dry-run: report the planned installation and write nothing.
if cli.dry_run {
print_dry_run(&spec, &target_dir);
return 0;
}
if let Err(e) = install::install_multiple(&spec, &target_dir) {
eprintln!("{} {e}", "Error:".red());
return 1;
}
// Post-install prompt execution (skipped in sandbox mode, like Node).
if let Some(prompt) = &cli.prompt {
if cli.sandbox.is_none() {
return run_prompt(prompt, &target_dir);
}
}
0
}
/// Print what `run_install` would do without touching the filesystem.
fn print_dry_run(spec: &MultiSpec, target_dir: &std::path::Path) {
println!(
"{}",
"🔍 Dry run — the following would be installed (no files written):".blue()
);
println!("📁 Target: {}", target_dir.display());
let groups: [(&str, &Vec<String>); 6] = [
("agent", &spec.agents),
("command", &spec.commands),
("mcp", &spec.mcps),
("setting", &spec.settings),
("hook", &spec.hooks),
("skill", &spec.skills),
];
for (label, items) in groups {
for item in items {
println!("{label}: {item}");
}
}
if spec.workflow_yaml.is_some() {
println!(" • workflow: .claude/workflows/<name>.yaml");
}
}
/// Run `claude -p "<prompt>"` in the target directory, inheriting stdio.
/// Returns the exit code to propagate: a non-zero `claude` exit is surfaced so
/// the overall run reflects the prompt failure; a missing `claude` binary is a
/// soft warning (the install itself already succeeded) and returns 0.
fn run_prompt(prompt: &str, target_dir: &std::path::Path) -> i32 {
println!("{}", "🚀 Executing prompt in Claude Code...".blue());
match Command::new("claude")
.arg("-p")
.arg(prompt)
.current_dir(target_dir)
.status()
{
Ok(status) if status.success() => 0,
Ok(status) => {
let code = status.code().unwrap_or(1);
println!(
"{}",
format!("⚠️ Prompt execution failed (claude exited with {code}).").yellow()
);
code
}
Err(_) => {
println!(
"{}",
"⚠️ Could not run `claude`. Is the Claude Code CLI installed and on PATH?"
.yellow()
);
0
}
}
}
+242
View File
@@ -0,0 +1,242 @@
//! JSON merge semantics for `.mcp.json`, settings, and hooks.
//!
//! These three have **different** merge rules in the Node CLI and must stay
//! distinct (see `index.js`):
//! - MCP: shallow top-level spread + per-server override (incoming wins).
//! - settings: deep-ish merge; `permissions.{allow,deny,ask}` deduped by Set
//! union; `env`/`hooks` shallow-merged.
//! - hooks: per-hook-type **array concatenation** (append, not overwrite).
use serde_json::{json, Map, Value};
/// Remove the `description` field from every server in `mcpServers`.
/// Mirrors the loop in `installIndividualMCP` before merging.
pub fn strip_mcp_descriptions(config: &mut Value) {
if let Some(servers) = config.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
for (_name, server) in servers.iter_mut() {
if let Some(obj) = server.as_object_mut() {
// `shift_remove` preserves key order (matches JS `delete`);
// plain `remove` would swap-remove and reorder keys.
obj.shift_remove("description");
}
}
}
}
/// Merge an incoming `.mcp.json` config into an existing one.
/// `{...existing, ...incoming}` then a per-server merge of `mcpServers`.
pub fn merge_mcp(existing: &Value, incoming: &Value) -> Value {
let mut merged = to_object(existing);
let incoming_obj = to_object(incoming);
// Top-level shallow spread: incoming wins.
for (k, v) in incoming_obj.iter() {
merged.insert(k.clone(), v.clone());
}
// Per-server merge only when both sides have mcpServers objects.
if let (Some(existing_servers), Some(incoming_servers)) = (
existing.get("mcpServers").and_then(Value::as_object),
incoming.get("mcpServers").and_then(Value::as_object),
) {
let mut servers = existing_servers.clone();
for (k, v) in incoming_servers.iter() {
servers.insert(k.clone(), v.clone());
}
merged.insert("mcpServers".to_string(), Value::Object(servers));
}
Value::Object(merged)
}
/// Merge an incoming setting config into an existing settings file.
pub fn merge_settings(existing: &Value, incoming: &Value) -> Value {
let mut merged = to_object(existing);
let incoming_obj = to_object(incoming);
// Top-level shallow spread: incoming wins.
for (k, v) in incoming_obj.iter() {
merged.insert(k.clone(), v.clone());
}
// permissions: object spread + array union for allow/deny/ask.
if let (Some(existing_perms), Some(incoming_perms)) = (
existing.get("permissions").and_then(Value::as_object),
incoming.get("permissions").and_then(Value::as_object),
) {
let mut perms = existing_perms.clone();
for (k, v) in incoming_perms.iter() {
perms.insert(k.clone(), v.clone());
}
for key in ["allow", "deny", "ask"] {
if let (Some(a), Some(b)) = (
existing_perms.get(key).and_then(Value::as_array),
incoming_perms.get(key).and_then(Value::as_array),
) {
perms.insert(key.to_string(), Value::Array(union_dedup(a, b)));
}
}
merged.insert("permissions".to_string(), Value::Object(perms));
}
// env: shallow merge (incoming wins).
if let (Some(a), Some(b)) = (
existing.get("env").and_then(Value::as_object),
incoming.get("env").and_then(Value::as_object),
) {
let mut env = a.clone();
for (k, v) in b.iter() {
env.insert(k.clone(), v.clone());
}
merged.insert("env".to_string(), Value::Object(env));
}
// hooks: shallow merge (incoming wins) — settings path only.
if let (Some(a), Some(b)) = (
existing.get("hooks").and_then(Value::as_object),
incoming.get("hooks").and_then(Value::as_object),
) {
let mut hooks = a.clone();
for (k, v) in b.iter() {
hooks.insert(k.clone(), v.clone());
}
merged.insert("hooks".to_string(), Value::Object(hooks));
}
Value::Object(merged)
}
/// Merge an incoming hook config into an existing settings file.
/// Per hook type, **concatenate** arrays (append); convert old string format to
/// the `{matcher:"*", hooks:[{type:"command", command}]}` shape.
pub fn merge_hooks(existing: &Value, incoming: &Value) -> Value {
let mut merged = to_object(existing);
// Ensure merged.hooks exists as an object.
let mut hooks = merged
.get("hooks")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if let Some(incoming_hooks) = incoming.get("hooks").and_then(Value::as_object) {
for (hook_type, incoming_val) in incoming_hooks.iter() {
match hooks.get(hook_type) {
None => {
// New hook type: copy as-is.
hooks.insert(hook_type.clone(), incoming_val.clone());
}
Some(existing_val) => {
if let Some(incoming_arr) = incoming_val.as_array() {
// New (array) format: append to existing array.
let mut base = existing_val.as_array().cloned().unwrap_or_default();
base.extend(incoming_arr.iter().cloned());
hooks.insert(hook_type.clone(), Value::Array(base));
} else {
// Old (string) format: wrap into a matcher object.
let mut base = existing_val.as_array().cloned().unwrap_or_default();
base.push(json!({
"matcher": "*",
"hooks": [{ "type": "command", "command": incoming_val }],
}));
hooks.insert(hook_type.clone(), Value::Array(base));
}
}
}
}
}
merged.insert("hooks".to_string(), Value::Object(hooks));
Value::Object(merged)
}
/// `[...new Set([...a, ...b])]` preserving first-occurrence order.
fn union_dedup(a: &[Value], b: &[Value]) -> Vec<Value> {
let mut seen: Vec<String> = Vec::new();
let mut out: Vec<Value> = Vec::new();
for v in a.iter().chain(b.iter()) {
let key = serde_json::to_string(v).unwrap_or_default();
if !seen.contains(&key) {
seen.push(key);
out.push(v.clone());
}
}
out
}
fn to_object(v: &Value) -> Map<String, Value> {
v.as_object().cloned().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mcp_merge_overrides_per_server_and_keeps_existing() {
let existing = json!({
"mcpServers": { "a": { "command": "old-a" } }
});
let mut incoming = json!({
"mcpServers": {
"a": { "command": "new-a", "description": "drop me" },
"b": { "command": "new-b" }
}
});
strip_mcp_descriptions(&mut incoming);
let merged = merge_mcp(&existing, &incoming);
assert_eq!(merged["mcpServers"]["a"]["command"], "new-a");
// description stripped before merge
assert!(merged["mcpServers"]["a"].get("description").is_none());
assert_eq!(merged["mcpServers"]["b"]["command"], "new-b");
}
#[test]
fn settings_permissions_arrays_are_set_unioned() {
let existing = json!({
"permissions": { "allow": ["Bash(npm:*)", "Read"] }
});
let incoming = json!({
"permissions": { "allow": ["Read", "Bash(git:*)"] }
});
let merged = merge_settings(&existing, &incoming);
assert_eq!(
merged["permissions"]["allow"],
json!(["Bash(npm:*)", "Read", "Bash(git:*)"])
);
}
#[test]
fn settings_env_shallow_merges() {
let existing = json!({ "env": { "A": "1", "B": "2" } });
let incoming = json!({ "env": { "B": "9", "C": "3" } });
let merged = merge_settings(&existing, &incoming);
assert_eq!(merged["env"], json!({ "A": "1", "B": "9", "C": "3" }));
}
#[test]
fn hooks_append_arrays_for_same_type() {
let existing = json!({
"hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [] }] }
});
let incoming = json!({
"hooks": { "PreToolUse": [{ "matcher": "Edit", "hooks": [] }] }
});
let merged = merge_hooks(&existing, &incoming);
let arr = merged["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["matcher"], "Bash");
assert_eq!(arr[1]["matcher"], "Edit");
}
#[test]
fn hooks_new_type_copied_verbatim() {
let existing = json!({});
let incoming = json!({
"hooks": { "PostToolUse": [{ "matcher": "*", "hooks": [] }] }
});
let merged = merge_hooks(&existing, &incoming);
assert!(merged["hooks"]["PostToolUse"].is_array());
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Windows Python-command compatibility shim.
//!
//! Mirrors `replacePythonCommands` in `index.js`: on Windows only, replace
//! `python3 ` with `python ` in the serialized JSON, then re-parse. No-op on
//! every other platform.
use serde_json::Value;
pub fn replace_python_commands(config: Value) -> Value {
if cfg!(target_os = "windows") {
if let Ok(s) = serde_json::to_string(&config) {
let replaced = s.replace("python3 ", "python ");
if let Ok(parsed) = serde_json::from_str(&replaced) {
return parsed;
}
}
}
config
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn non_windows_is_noop() {
// On the CI/dev platforms (non-Windows) the config is returned as-is.
let cfg = json!({ "cmd": "python3 script.py" });
let out = replace_python_commands(cfg.clone());
if cfg!(target_os = "windows") {
assert_eq!(out["cmd"], "python script.py");
} else {
assert_eq!(out, cfg);
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//! 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");
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Filesystem helpers that reproduce the byte-level behavior of the Node CLI's
//! `fs-extra` usage (`ensureDir`, `readJson`, `writeJson({spaces:2})`, `chmod`).
use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::Path;
/// Equivalent of `fs.ensureDir` — create a directory and all parents.
pub fn ensure_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("failed to create directory {}", dir.display()))
}
/// Equivalent of `fs.readJson` — read + parse a JSON file into a `Value`.
pub fn read_json(path: &Path) -> Result<Value> {
let text =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let value = serde_json::from_str(&text)
.with_context(|| format!("failed to parse JSON {}", path.display()))?;
Ok(value)
}
/// Equivalent of `fs.writeJson(path, value, {spaces: 2})`.
///
/// `serde_json::to_string_pretty` uses a 2-space indent, matching Node's
/// `{spaces: 2}`. `fs-extra`/`jsonfile` appends a trailing newline by default
/// (`finalEOL: true`), so we append one too for byte parity.
pub fn write_json_pretty(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
let mut text = serde_json::to_string_pretty(value).context("failed to serialize JSON")?;
text.push('\n');
fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
/// Write a text file (UTF-8), creating parent directories. Optionally mark it
/// executable (`chmod 0o755`) on Unix; a no-op on Windows.
pub fn write_file(path: &Path, content: &str, executable: bool) -> Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
if executable {
make_executable(path)?;
}
Ok(())
}
/// `chmod 0o755` on Unix; no-op elsewhere.
#[cfg(unix)]
pub fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)
.with_context(|| format!("failed to stat {}", path.display()))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.with_context(|| format!("failed to chmod {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
pub fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
//! Small filesystem + path helpers shared across commands.
pub mod fs_ext;
pub mod paths;
+145
View File
@@ -0,0 +1,145 @@
//! Path resolution helpers: home dir, settings-location mapping, enterprise
//! managed-settings directories, and relative-path computation for tracking.
use directories::UserDirs;
use std::path::{Path, PathBuf};
/// Resolve the user's home directory (equivalent to `os.homedir()`).
pub fn home_dir() -> PathBuf {
UserDirs::new()
.map(|d| d.home_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
/// A resolved install location for a setting/hook: the directory the settings
/// file lives in and the filename. Mirrors the branching in
/// `installIndividualSetting` / `installIndividualHook`.
pub struct ResolvedLocation {
/// Base dir the additional files resolve against (`currentTargetDir`).
pub current_target_dir: PathBuf,
/// Absolute path of the settings JSON file to merge into.
pub settings_file: PathBuf,
}
/// Map a location keyword (`user`/`project`/`local`/`enterprise`) to concrete
/// paths, given the project `target_dir`. Returns `None` for unsupported
/// enterprise platforms (Node falls back to user settings in that case — the
/// caller handles the fallback).
pub fn resolve_location(location: &str, target_dir: &Path) -> ResolvedLocation {
match location {
"user" => {
let dir = home_dir();
ResolvedLocation {
settings_file: dir.join(".claude").join("settings.json"),
current_target_dir: dir,
}
}
"project" => ResolvedLocation {
current_target_dir: target_dir.to_path_buf(),
settings_file: target_dir.join(".claude").join("settings.json"),
},
"enterprise" => {
if let Some(dir) = enterprise_dir() {
let file = dir.join("managed-settings.json");
ResolvedLocation {
current_target_dir: dir,
settings_file: file,
}
} else {
// Fallback to user settings (matches Node's else branch).
let dir = home_dir();
ResolvedLocation {
settings_file: dir.join(".claude").join("settings.json"),
current_target_dir: dir,
}
}
}
// "local" and any unknown value default to local settings.
_ => ResolvedLocation {
current_target_dir: target_dir.to_path_buf(),
settings_file: target_dir.join(".claude").join("settings.local.json"),
},
}
}
/// Enterprise managed-settings directory per platform.
/// macOS: `/Library/Application Support/ClaudeCode`
/// Linux/WSL: `/etc/claude-code`
/// Windows: `C:\ProgramData\ClaudeCode`
fn enterprise_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
Some(PathBuf::from("/Library/Application Support/ClaudeCode"))
} else if cfg!(target_os = "linux") {
Some(PathBuf::from("/etc/claude-code"))
} else if cfg!(target_os = "windows") {
Some(PathBuf::from("C:\\ProgramData\\ClaudeCode"))
} else {
None
}
}
/// Resolve an "additional file" path the way Node does: a leading `~` expands
/// to the home directory, otherwise it resolves against `current_target_dir`.
pub fn resolve_additional_file(file_path: &str, current_target_dir: &Path) -> PathBuf {
if let Some(rest) = file_path.strip_prefix('~') {
// `path.join(homedir(), filePath.slice(1))` — strip the `~`, keep rest.
let rest = rest.strip_prefix('/').unwrap_or(rest);
home_dir().join(rest)
} else {
current_target_dir.join(file_path)
}
}
/// Best-effort equivalent of `path.relative(from, to)` for tracking metadata.
pub fn relative_path(from: &Path, to: &Path) -> String {
pathdiff::diff_paths(to, from)
.unwrap_or_else(|| to.to_path_buf())
.to_string_lossy()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn local_location_uses_settings_local_json() {
let target = PathBuf::from("/tmp/proj");
let r = resolve_location("local", &target);
assert_eq!(r.settings_file, target.join(".claude/settings.local.json"));
assert_eq!(r.current_target_dir, target);
}
#[test]
fn project_location_uses_settings_json() {
let target = PathBuf::from("/tmp/proj");
let r = resolve_location("project", &target);
assert_eq!(r.settings_file, target.join(".claude/settings.json"));
}
#[test]
fn user_location_resolves_under_home() {
let r = resolve_location("user", Path::new("/tmp/proj"));
assert_eq!(r.settings_file, home_dir().join(".claude/settings.json"));
assert_eq!(r.current_target_dir, home_dir());
}
#[test]
fn additional_file_expands_tilde_to_home() {
let resolved = resolve_additional_file("~/.claude/x.py", Path::new("/tmp/proj"));
assert_eq!(resolved, home_dir().join(".claude/x.py"));
}
#[test]
fn additional_file_resolves_relative_against_target() {
let resolved = resolve_additional_file(".claude/scripts/s.py", Path::new("/tmp/proj"));
assert_eq!(resolved, PathBuf::from("/tmp/proj/.claude/scripts/s.py"));
}
#[test]
fn relative_path_computes_dotdot() {
let rel = relative_path(Path::new("/a/b"), Path::new("/a/c/d"));
assert_eq!(rel, "../c/d");
}
}
+100
View File
@@ -0,0 +1,100 @@
//! End-to-end tests that drive the compiled `cct` binary.
//!
//! Network-dependent tests (real GitHub fetch) are marked `#[ignore]` so the
//! default `cargo test` stays offline/hermetic. Run them with:
//!
//! cargo test -- --ignored
//!
//! Cargo exposes the built binary path via `CARGO_BIN_EXE_cct`.
use std::path::PathBuf;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_cct")
}
#[test]
fn help_exits_zero_and_shows_usage() {
let out = Command::new(bin())
.arg("--help")
.output()
.expect("failed to run cct");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Usage:"),
"help output missing Usage:\n{stdout}"
);
}
#[test]
fn version_flag_works() {
let out = Command::new(bin())
.arg("--version")
.output()
.expect("failed to run cct");
assert!(out.status.success());
}
#[test]
#[ignore = "requires network (GitHub raw)"]
fn installs_agent_to_flat_dir() {
let dir = tempfile::tempdir().unwrap();
let status = Command::new(bin())
.args([
"--agent",
"deep-research-team/research-synthesizer",
"-d",
dir.path().to_str().unwrap(),
])
.env("CCT_NO_TRACKING", "1")
.status()
.expect("failed to run cct");
assert!(status.success());
// Category dropped: file lands flat under .claude/agents/.
let expected: PathBuf = dir.path().join(".claude/agents/research-synthesizer.md");
assert!(
expected.exists(),
"expected {} to exist",
expected.display()
);
}
#[test]
#[ignore = "requires network (GitHub raw)"]
fn installs_mcp_with_two_space_json_and_no_description() {
let dir = tempfile::tempdir().unwrap();
let status = Command::new(bin())
.args([
"--mcp",
"devtools/elasticsearch",
"-d",
dir.path().to_str().unwrap(),
])
.env("CCT_NO_TRACKING", "1")
.status()
.expect("failed to run cct");
assert!(status.success());
let mcp = dir.path().join(".mcp.json");
let text = std::fs::read_to_string(&mcp).unwrap();
assert!(text.contains("\"mcpServers\""));
// A pretty-printed 2-space top-level key looks like `\n "key"`.
assert!(
text.contains("\n \""),
"expected a line indented with exactly two spaces"
);
// And NOT 4-space indentation at the top level.
assert!(
!text.contains("\n \"mcpServers\""),
"did not expect 4-space indentation"
);
assert!(text.ends_with('\n'), "expected trailing newline");
// description is stripped from each server before merge.
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
for (_name, server) in v["mcpServers"].as_object().unwrap() {
assert!(server.get("description").is_none());
}
}