chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.
|
||||
Generated
+1884
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Command implementations: native installers + Node delegation.
|
||||
|
||||
pub mod delegate;
|
||||
pub mod install;
|
||||
@@ -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/"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Small filesystem + path helpers shared across commands.
|
||||
|
||||
pub mod fs_ext;
|
||||
pub mod paths;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user