speccoder_runtime/extensions/
add.rs1use std::path::{Path, PathBuf};
7
8use super::{DIR, Loaded};
9
10pub fn add(root: &Path, source: &str) -> Result<Loaded, String> {
18 let project = super::project(root);
19 let fetched;
20 let (from, origin) = if Path::new(source).is_dir() {
21 (PathBuf::from(source), None)
22 } else {
23 if source.starts_with('-') {
24 return Err(format!("`{source}` is not a folder or a repository URL"));
25 }
26 let (url, commit) = source.rsplit_once('@').ok_or_else(|| {
27 format!("{source} is neither a folder nor URL@<commit> (a full 40-character commit)")
28 })?;
29 if commit.len() != 40 || !commit.bytes().all(|b| b.is_ascii_hexdigit()) {
30 return Err(format!(
31 "`{commit}` is not a full commit: give URL@<40 hex characters>, so what you trust is what was fetched"
32 ));
33 }
34 fetched = fetch(url, commit)?;
35 (
36 fetched.path().join("tree"),
37 Some((url.to_owned(), commit.to_owned())),
38 )
39 };
40 let mut bytes: u64 = 0;
42 let listed = super::walk(&from, true)?;
43 if listed.len() > speccoder_core::extensions::MAX_FILES {
44 return Err(format!(
45 "more than {} files: an extension holds at most that many",
46 speccoder_core::extensions::MAX_FILES
47 ));
48 }
49 for rel in &listed {
50 let meta = std::fs::symlink_metadata(from.join(rel)).map_err(|e| format!("{rel}: {e}"))?;
51 if !meta.file_type().is_file() {
52 return Err(format!("{rel} is not a regular file"));
53 }
54 bytes += meta.len();
55 if bytes > speccoder_core::extensions::MAX_BYTES {
56 return Err("more than 20 MB: an extension holds at most that much".to_owned());
57 }
58 }
59 let staged = tempfile::tempdir().map_err(|e| e.to_string())?;
60 let name_dir = staged.path().join("x");
61 copy(&from, &name_dir)?;
62 let _ = std::fs::remove_file(name_dir.join(super::NAMESPACE).join("source.toml"));
64 if let Some((url, commit)) = &origin {
65 let part = name_dir.join(super::NAMESPACE);
66 std::fs::create_dir_all(&part).map_err(|e| e.to_string())?;
67 let text = format!(
68 "# Where `speccoder extensions add` took this extension from.\nurl = {url:?}\ncommit = {commit:?}\nadded = {:?}\n",
69 crate::attest::now_rfc3339()
70 );
71 std::fs::write(part.join("source.toml"), text).map_err(|e| e.to_string())?;
72 }
73 let name = serde_json::from_str::<serde_json::Value>(
75 &std::fs::read_to_string(name_dir.join("plugin.json")).map_err(|_| {
76 format!("{source} has no plugin.json: an extension is an Agent Plugins 1.0 folder")
77 })?,
78 )
79 .ok()
80 .and_then(|v| v["name"].as_str().map(str::to_owned))
81 .ok_or_else(|| format!("{source}: plugin.json names no extension"))?;
82 if !speccoder_core::extensions::valid_name(&name) {
83 return Err(format!("`{name}` is not a valid extension name"));
84 }
85 let target = project.join(DIR).join(&name);
86 if target.exists() || super::load(&project).iter().any(|e| e.name == name) {
87 return Err(format!(
88 "`{name}` is already here: `speccoder extensions remove {name}` first"
89 ));
90 }
91 std::fs::create_dir_all(project.join(DIR)).map_err(|e| e.to_string())?;
92 let renamed = staged.path().join(&name);
93 std::fs::rename(&name_dir, &renamed).map_err(|e| e.to_string())?;
94 let loaded = super::one(
95 &project,
96 &renamed,
97 speccoder_core::extensions::ExtensionSource::AgentPlugins,
98 );
99 if !loaded.usable() {
100 return Err(format!(
101 "{name} cannot be loaded: {}",
102 loaded.problems.join("; ")
103 ));
104 }
105 copy(&renamed, &target)?;
106 super::load(&project)
107 .into_iter()
108 .find(|e| e.name == name)
109 .ok_or_else(|| format!("{name} was copied but does not load"))
110}
111
112pub fn remove(root: &Path, state: &Path, name: &str) -> Result<(), String> {
118 let all = super::load(root);
119 let Some(e) = all.iter().find(|e| e.name == name) else {
120 return Err(format!("there is no extension `{name}`"));
121 };
122 if e.source == speccoder_core::extensions::ExtensionSource::SpecKit {
123 return Err(format!(
124 "{name} is a Spec Kit extension: `specify extension remove {name}` removes it"
125 ));
126 }
127 std::fs::remove_dir_all(&e.folder).map_err(|err| format!("{}: {err}", e.rel))?;
128 crate::hooks::trust::forget(&crate::hooks::dir(state, root), &super::trust::key(name));
129 Ok(())
130}
131
132fn fetch(url: &str, commit: &str) -> Result<tempfile::TempDir, String> {
135 let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
136 let tree = tmp.path().join("tree");
137 let no_hooks = tmp.path().join("no-hooks");
138 std::fs::create_dir_all(&no_hooks).map_err(|e| e.to_string())?;
139 let local = !url.contains("://") && !url.contains('@');
140 let git = |args: &[&str]| -> Result<(), String> {
141 let mut c = std::process::Command::new("git");
142 c.arg("-c")
143 .arg(format!("core.hooksPath={}", no_hooks.display()))
144 .args(["-c", "submodule.recurse=false"]);
145 if !local {
146 c.args(["-c", "protocol.file.allow=never"]);
147 }
148 let out = c
149 .args(args)
150 .current_dir(&tree)
151 .env("GIT_CONFIG_NOSYSTEM", "1")
152 .env("GIT_TERMINAL_PROMPT", "0")
153 .output()
154 .map_err(|e| {
155 format!("git is needed to add from a repository ({e}); add a folder instead")
156 })?;
157 if out.status.success() {
158 Ok(())
159 } else {
160 Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
161 }
162 };
163 std::fs::create_dir_all(&tree).map_err(|e| e.to_string())?;
164 git(&["init", "--quiet"])?;
165 git(&["fetch", "--quiet", "--depth", "1", "--", url, commit])
166 .map_err(|e| format!("could not fetch {commit} from {url}: {e}"))?;
167 git(&["checkout", "--quiet", "FETCH_HEAD"])?;
168 Ok(tmp)
169}
170
171fn copy(from: &Path, to: &Path) -> Result<(), String> {
174 std::fs::create_dir_all(to).map_err(|e| e.to_string())?;
175 for entry in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {
176 let entry = entry.map_err(|e| e.to_string())?;
177 let path = entry.path();
178 if path.file_name().is_some_and(|n| n == ".git") {
179 continue;
180 }
181 let kind = entry.file_type().map_err(|e| e.to_string())?;
182 let dest = to.join(entry.file_name());
183 if kind.is_symlink() {
184 return Err(format!(
185 "{} is a link: an extension holds files only",
186 path.display()
187 ));
188 } else if kind.is_dir() {
189 copy(&path, &dest)?;
190 } else {
191 std::fs::copy(&path, &dest).map_err(|e| format!("{}: {e}", path.display()))?;
192 }
193 }
194 Ok(())
195}