Scaffold Rust project with CLI, config, git, lockfile, and paths
Assisted-by: Claude Opus 4.6 via pi
change swymmzsnzxrmpyxrrtutnqmtlkrkmxms
commit 0af4f9bf762cff9518e13aacd7efc34b5c3f83f5
author Alpha Chen <alpha@kejadlen.dev>
date
parent orpktskp
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..accd018
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,556 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.5.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "color-eyre"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
+dependencies = [
+ "backtrace",
+ "color-spantrace",
+ "eyre",
+ "indenter",
+ "once_cell",
+ "owo-colors",
+ "tracing-error",
+]
+
+[[package]]
+name = "color-spantrace"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
+dependencies = [
+ "once_cell",
+ "owo-colors",
+ "tracing-core",
+ "tracing-error",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "libyml"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
+dependencies = [
+ "anyhow",
+ "version_check",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "owo-colors"
+version = "4.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pinch"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "color-eyre",
+ "serde",
+ "serde_yml",
+ "tracing",
+ "tracing-subscriber",
+ "xdg",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yml"
+version = "0.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "libyml",
+ "memchr",
+ "ryu",
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.116"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-error"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "xdg"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..187ab7b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "pinch"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+clap = { version = "*", features = ["derive"] }
+color-eyre = "*"
+tracing = "*"
+tracing-subscriber = "*"
+serde = { version = "*", features = ["derive"] }
+serde_yml = "*"
+xdg = "*"
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..a7639ba
--- /dev/null
+++ b/justfile
@@ -0,0 +1,10 @@
+check:
+    cargo check
+
+fmt:
+    cargo fmt
+
+clippy:
+    cargo clippy
+
+lint: fmt check clippy
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..a83659d
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,70 @@
+use std::collections::HashSet;
+
+use color_eyre::eyre::{Context, Result, bail};
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+pub struct ConfigFile {
+    #[serde(default)]
+    pub plugins: Vec<Plugin>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Plugin {
+    pub name: String,
+    pub src: String,
+    pub path: String,
+    pub r#ref: String,
+}
+
+#[derive(Debug)]
+pub struct Config {
+    pub plugins: Vec<Plugin>,
+}
+
+pub fn load() -> Result<Config> {
+    let config_dir = crate::paths::config_dir();
+
+    if !config_dir.is_dir() {
+        bail!(
+            "config directory not found: {}\ncreate it and add .yml config files",
+            config_dir.display()
+        );
+    }
+
+    let mut entries: Vec<_> = std::fs::read_dir(&config_dir)
+        .with_context(|| format!("failed to read config dir: {}", config_dir.display()))?
+        .filter_map(|e| e.ok())
+        .filter(|e| e.path().extension().is_some_and(|ext| ext == "yml"))
+        .collect();
+
+    entries.sort_by_key(|e| e.file_name());
+
+    if entries.is_empty() {
+        bail!("no .yml files found in {}", config_dir.display());
+    }
+
+    let mut plugins = Vec::new();
+    let mut seen_names = HashSet::new();
+
+    for entry in entries {
+        let path = entry.path();
+        let contents = std::fs::read_to_string(&path)
+            .with_context(|| format!("failed to read {}", path.display()))?;
+        let file: ConfigFile = serde_yml::from_str(&contents)
+            .with_context(|| format!("failed to parse {}", path.display()))?;
+
+        for plugin in file.plugins {
+            if !seen_names.insert(plugin.name.clone()) {
+                bail!(
+                    "duplicate plugin name '{}' (found in {})",
+                    plugin.name,
+                    path.display()
+                );
+            }
+            plugins.push(plugin);
+        }
+    }
+
+    Ok(Config { plugins })
+}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..a96574f
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,81 @@
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use color_eyre::eyre::{Context, Result, bail};
+
+/// Derive a local repo path from a remote URL.
+/// e.g. "https://github.com/user/repo" -> repos_dir/github.com/user/repo
+pub fn repo_path(repos_dir: &Path, src: &str) -> PathBuf {
+    let url = src
+        .strip_prefix("https://")
+        .or_else(|| src.strip_prefix("http://"))
+        .unwrap_or(src)
+        .trim_end_matches(".git");
+    repos_dir.join(url)
+}
+
+pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
+    if repo_path.join(".git").is_dir() {
+        let status = Command::new("git")
+            .args(["fetch", "--quiet", "origin"])
+            .current_dir(repo_path)
+            .status()
+            .context("failed to run git fetch")?;
+        if !status.success() {
+            bail!("git fetch failed for {}", src);
+        }
+    } else {
+        std::fs::create_dir_all(repo_path)
+            .with_context(|| format!("failed to create {}", repo_path.display()))?;
+        let status = Command::new("git")
+            .args(["clone", "--quiet", src, "."])
+            .current_dir(repo_path)
+            .status()
+            .context("failed to run git clone")?;
+        if !status.success() {
+            bail!("git clone failed for {}", src);
+        }
+    }
+    Ok(())
+}
+
+pub fn resolve_ref(repo_path: &Path, git_ref: &str) -> Result<String> {
+    let output = Command::new("git")
+        .args(["rev-parse", &format!("origin/{git_ref}")])
+        .current_dir(repo_path)
+        .output()
+        .context("failed to run git rev-parse")?;
+
+    if output.status.success() {
+        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
+    }
+
+    // Fall back to treating it as a tag or raw ref
+    let output = Command::new("git")
+        .args(["rev-parse", git_ref])
+        .current_dir(repo_path)
+        .output()
+        .context("failed to run git rev-parse")?;
+
+    if !output.status.success() {
+        bail!(
+            "could not resolve ref '{}' in {}",
+            git_ref,
+            repo_path.display()
+        );
+    }
+
+    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+pub fn checkout(repo_path: &Path, rev: &str) -> Result<()> {
+    let status = Command::new("git")
+        .args(["checkout", "--quiet", "--detach", rev])
+        .current_dir(repo_path)
+        .status()
+        .context("failed to run git checkout")?;
+    if !status.success() {
+        bail!("git checkout failed for {} in {}", rev, repo_path.display());
+    }
+    Ok(())
+}
diff --git a/src/lockfile.rs b/src/lockfile.rs
new file mode 100644
index 0000000..10aec1f
--- /dev/null
+++ b/src/lockfile.rs
@@ -0,0 +1,37 @@
+use color_eyre::eyre::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Lockfile {
+    #[serde(default)]
+    pub plugins: Vec<LockedPlugin>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LockedPlugin {
+    pub name: String,
+    pub src: String,
+    pub path: String,
+    pub rev: String,
+}
+
+pub fn load() -> Result<Lockfile> {
+    let path = crate::paths::lockfile_path();
+    let contents = std::fs::read_to_string(&path)
+        .with_context(|| format!("failed to read lockfile: {}", path.display()))?;
+    let lockfile: Lockfile = serde_yml::from_str(&contents)
+        .with_context(|| format!("failed to parse lockfile: {}", path.display()))?;
+    Ok(lockfile)
+}
+
+pub fn save(lockfile: &Lockfile) -> Result<()> {
+    let path = crate::paths::lockfile_path();
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)
+            .with_context(|| format!("failed to create data dir: {}", parent.display()))?;
+    }
+    let contents = serde_yml::to_string(lockfile).context("failed to serialize lockfile")?;
+    std::fs::write(&path, contents)
+        .with_context(|| format!("failed to write lockfile: {}", path.display()))?;
+    Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..d3f9fee
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,196 @@
+mod config;
+mod git;
+mod lockfile;
+mod paths;
+
+use clap::{Parser, Subcommand};
+use color_eyre::eyre::{Context, Result, bail};
+use tracing::info;
+
+#[derive(Parser)]
+#[command(name = "pinch", about = "A plugin manager for Claude Code and pi")]
+struct Cli {
+    #[command(subcommand)]
+    command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+    /// Install plugins from the lockfile
+    Install {
+        /// Run update before installing
+        #[arg(long)]
+        update: bool,
+    },
+    /// Update the lockfile from config
+    Update {
+        /// Install after updating
+        #[arg(long)]
+        install: bool,
+        /// Update only the named plugin(s)
+        names: Vec<String>,
+    },
+}
+
+fn main() -> Result<()> {
+    color_eyre::install()?;
+    tracing_subscriber::fmt::init();
+
+    let cli = Cli::parse();
+
+    match cli.command {
+        Command::Install { update } => {
+            if update {
+                do_update(&[])?;
+            }
+            do_install()?;
+        }
+        Command::Update { install, names } => {
+            do_update(&names)?;
+            if install {
+                do_install()?;
+            }
+        }
+    }
+
+    Ok(())
+}
+
+fn do_update(names: &[String]) -> Result<()> {
+    let config = config::load().context("failed to load config")?;
+
+    let plugins: Vec<_> = if names.is_empty() {
+        config.plugins.clone()
+    } else {
+        config
+            .plugins
+            .iter()
+            .filter(|p| names.contains(&p.name))
+            .cloned()
+            .collect()
+    };
+
+    if plugins.is_empty() && !names.is_empty() {
+        bail!("no plugins found matching: {}", names.join(", "));
+    }
+
+    let mut lockfile = if names.is_empty() {
+        lockfile::Lockfile { plugins: vec![] }
+    } else {
+        lockfile::load().unwrap_or_else(|_| lockfile::Lockfile { plugins: vec![] })
+    };
+
+    let repos_dir = paths::repos_dir();
+    std::fs::create_dir_all(&repos_dir)
+        .with_context(|| format!("failed to create repos dir: {}", repos_dir.display()))?;
+
+    for plugin in &plugins {
+        info!("updating {}...", plugin.name);
+        let repo_path = git::repo_path(&repos_dir, &plugin.src);
+        git::clone_or_fetch(&repo_path, &plugin.src)?;
+        let rev = git::resolve_ref(&repo_path, &plugin.r#ref)?;
+        info!("  {} -> {}", plugin.r#ref, &rev[..12]);
+
+        // Remove existing entry for this plugin if doing selective update
+        if !names.is_empty() {
+            lockfile.plugins.retain(|p| p.name != plugin.name);
+        }
+
+        lockfile.plugins.push(lockfile::LockedPlugin {
+            name: plugin.name.clone(),
+            src: plugin.src.clone(),
+            path: plugin.path.clone(),
+            rev,
+        });
+    }
+
+    lockfile::save(&lockfile).context("failed to save lockfile")?;
+    info!("lockfile updated");
+    Ok(())
+}
+
+fn do_install() -> Result<()> {
+    let lockfile = lockfile::load().context("no lockfile found — run `pinch update` first")?;
+
+    let repos_dir = paths::repos_dir();
+    let skills_dir = paths::skills_dir();
+    std::fs::create_dir_all(&skills_dir)
+        .with_context(|| format!("failed to create skills dir: {}", skills_dir.display()))?;
+
+    let cache_dir = paths::cache_dir();
+
+    for plugin in &lockfile.plugins {
+        let repo_path = git::repo_path(&repos_dir, &plugin.src);
+        git::checkout(&repo_path, &plugin.rev)?;
+
+        let skill_source = repo_path.join(&plugin.path);
+        if !skill_source.is_dir() {
+            bail!(
+                "plugin '{}': path '{}' does not exist in repo at rev {}",
+                plugin.name,
+                plugin.path,
+                &plugin.rev[..12]
+            );
+        }
+
+        // Verify it's a skill (has SKILL.md)
+        if !skill_source.join("SKILL.md").is_file() {
+            bail!(
+                "plugin '{}': path '{}' has no SKILL.md — only skills are supported",
+                plugin.name,
+                plugin.path
+            );
+        }
+
+        let symlink_path = skills_dir.join(&plugin.name);
+
+        // Remove existing symlink if present
+        if symlink_path.symlink_metadata().is_ok() {
+            std::fs::remove_file(&symlink_path).with_context(|| {
+                format!(
+                    "failed to remove existing symlink: {}",
+                    symlink_path.display()
+                )
+            })?;
+        }
+
+        std::os::unix::fs::symlink(&skill_source, &symlink_path).with_context(|| {
+            format!(
+                "failed to create symlink: {} -> {}",
+                symlink_path.display(),
+                skill_source.display()
+            )
+        })?;
+
+        info!("installed {} -> {}", plugin.name, skill_source.display());
+    }
+
+    // Clean up stale pinch-managed symlinks
+    let locked_names: std::collections::HashSet<_> =
+        lockfile.plugins.iter().map(|p| p.name.as_str()).collect();
+
+    for entry in std::fs::read_dir(&skills_dir)? {
+        let entry = entry?;
+        let path = entry.path();
+
+        // Only consider symlinks
+        if !path.symlink_metadata()?.file_type().is_symlink() {
+            continue;
+        }
+
+        let name = entry.file_name();
+        let name_str = name.to_string_lossy();
+
+        // Only touch symlinks pointing into our cache
+        if let Ok(target) = std::fs::read_link(&path)
+            && target.starts_with(&cache_dir)
+            && !locked_names.contains(name_str.as_ref())
+        {
+            std::fs::remove_file(&path)?;
+            info!("removed stale symlink: {}", name_str);
+        }
+    }
+
+    info!("install complete");
+    Ok(())
+}
diff --git a/src/paths.rs b/src/paths.rs
new file mode 100644
index 0000000..c147db0
--- /dev/null
+++ b/src/paths.rs
@@ -0,0 +1,42 @@
+use std::path::PathBuf;
+
+use xdg::BaseDirectories;
+
+fn base_dirs() -> BaseDirectories {
+    BaseDirectories::with_prefix("pinch")
+}
+
+pub fn config_dir() -> PathBuf {
+    base_dirs()
+        .get_config_home()
+        .expect("XDG config home not set")
+        .join("conf.d")
+}
+
+pub fn cache_dir() -> PathBuf {
+    base_dirs()
+        .get_cache_home()
+        .expect("XDG cache home not set")
+}
+
+pub fn repos_dir() -> PathBuf {
+    cache_dir().join("repos")
+}
+
+pub fn data_dir() -> PathBuf {
+    base_dirs().get_data_home().expect("XDG data home not set")
+}
+
+pub fn lockfile_path() -> PathBuf {
+    data_dir().join("pinch-lock.yml")
+}
+
+pub fn skills_dir() -> PathBuf {
+    home_dir().join(".claude").join("skills")
+}
+
+fn home_dir() -> PathBuf {
+    std::env::var_os("HOME")
+        .map(PathBuf::from)
+        .expect("could not determine home directory")
+}