1pub mod add;
10pub mod report;
11pub mod trust;
12pub mod views;
13
14use std::path::{Path, PathBuf};
15
16use sha2::{Digest as _, Sha256};
17use speccoder_core::extensions::{
18 self as core, AddedFrom, Contribution, ContributionKind, ExtensionSource, ViewDef, ViewSource,
19 hook_contribution, view_contribution,
20};
21use speccoder_core::hooks::{HookDef, HookFile, TrustState};
22
23pub const DIR: &str = ".speccoder/extensions";
25pub const NAMESPACE: &str = "dev.speccoder";
27
28const PLUGIN_KEYS: &[&str] = &[
29 "$schema",
30 "name",
31 "version",
32 "description",
33 "author",
34 "homepage",
35 "repository",
36 "license",
37 "keywords",
38 "extensions",
39];
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Server {
44 pub name: String,
46 pub command: Vec<String>,
48 pub env: Vec<(String, String)>,
50 pub cwd: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Offer {
57 pub event: String,
59 pub command: String,
61 pub prompt: Option<String>,
63 pub mandatory: bool,
65}
66
67#[derive(Debug, Clone)]
69pub struct Loaded {
70 pub name: String,
72 pub source: ExtensionSource,
74 pub folder: PathBuf,
76 pub rel: String,
78 pub version: Option<String>,
80 pub description: Option<String>,
82 pub origin: Option<AddedFrom>,
84 pub digest: String,
86 pub files: Vec<HookFile>,
88 pub hooks: Vec<HookDef>,
90 pub views: Vec<ViewDef>,
92 pub skills: Vec<(String, PathBuf)>,
94 pub servers: Vec<Server>,
96 pub commands: Vec<String>,
98 pub offers: Vec<Offer>,
100 pub problems: Vec<String>,
102}
103
104impl Loaded {
105 #[must_use]
107 pub fn usable(&self) -> bool {
108 self.problems.is_empty()
109 }
110
111 #[must_use]
113 pub fn contributions(&self) -> Vec<Contribution> {
114 let mut out: Vec<Contribution> = self
115 .hooks
116 .iter()
117 .map(|h| hook_contribution(&h.name, h))
118 .collect();
119 out.extend(
120 self.views
121 .iter()
122 .map(|v| view_contribution(&format!("{}.{}", self.name, v.name), v)),
123 );
124 out.extend(self.skills.iter().map(|(n, _)| Contribution {
125 kind: ContributionKind::Skill,
126 name: format!("/{n}"),
127 line: format!("skill /{n}: a prompt you run from the composer"),
128 reaches: Vec::new(),
129 }));
130 out.extend(self.servers.iter().map(|s| Contribution {
131 kind: ContributionKind::McpServer,
132 name: s.name.clone(),
133 line: format!(
134 "MCP server {}: starts {} in the sandbox; each call asks unless allowed",
135 s.name,
136 core::shown(&s.command)
137 ),
138 reaches: vec![format!("server:{}", s.name)],
139 }));
140 out.extend(self.commands.iter().map(|c| Contribution {
141 kind: ContributionKind::Command,
142 name: c.clone(),
143 line: format!("command {c}: a prompt you run from the palette"),
144 reaches: Vec::new(),
145 }));
146 let mut seen: Vec<(String, bool)> = Vec::new();
148 for o in &self.offers {
149 if seen.contains(&(o.command.clone(), o.mandatory)) {
150 continue;
151 }
152 seen.push((o.command.clone(), o.mandatory));
153 let events: Vec<&str> = self
154 .offers
155 .iter()
156 .filter(|x| x.command == o.command && x.mandatory == o.mandatory)
157 .map(|x| x.event.as_str())
158 .collect();
159 out.push(Contribution {
160 kind: ContributionKind::HookOffer,
161 name: format!(
162 "{} {}",
163 if o.mandatory { "runs" } else { "offers" },
164 o.command
165 ),
166 line: if o.mandatory {
167 format!(
168 "runs {} at {} (Spec Kit hook)",
169 o.command,
170 events.join(", ")
171 )
172 } else {
173 format!("offers {} at {}", o.command, events.join(", "))
174 },
175 reaches: if o.mandatory {
176 events.iter().map(|e| format!("hook:{e}")).collect()
177 } else {
178 Vec::new()
179 },
180 });
181 }
182 out
183 }
184}
185
186#[must_use]
188pub fn project(root: &Path) -> PathBuf {
189 crate::verify::find_config(root).unwrap_or_else(|| root.to_path_buf())
190}
191
192#[must_use]
194pub fn load(root: &Path) -> Vec<Loaded> {
195 let project = project(root);
196 let mut out = Vec::new();
197 let mut folders: Vec<PathBuf> = std::fs::read_dir(project.join(DIR))
198 .into_iter()
199 .flatten()
200 .flatten()
201 .map(|e| e.path())
202 .filter(|p| {
204 (p.is_dir() || std::fs::symlink_metadata(p).is_ok_and(|m| m.file_type().is_symlink()))
205 && !p
206 .file_name()
207 .and_then(|n| n.to_str())
208 .is_some_and(|n| n.starts_with('.'))
209 })
210 .collect();
211 folders.sort();
212 for folder in folders {
213 out.push(one(&project, &folder, ExtensionSource::AgentPlugins));
214 }
215 for folder in crate::format::speckit::extensions::folders(&project) {
217 out.push(one(&project, &folder, ExtensionSource::SpecKit));
218 }
219 let mut servers: Vec<String> = crate::verify::find_config(&project)
222 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
223 .and_then(|t| crate::mcp::parse(&t).ok())
224 .unwrap_or_default()
225 .into_iter()
226 .map(|s| s.name)
227 .collect();
228 for e in &mut out {
229 for s in &e.servers {
230 if servers.contains(&s.name) {
231 e.problems.push(format!(
232 "mcp.json server {}: the name is taken by another MCP server",
233 s.name
234 ));
235 } else {
236 servers.push(s.name.clone());
237 }
238 }
239 }
240 for i in 0..out.len() {
242 if let Some(first) = out[..i].iter().find(|e| e.name == out[i].name) {
243 let line = format!("the name `{}` is taken by {}", out[i].name, first.rel);
244 out[i].problems.push(line);
245 }
246 }
247 out
248}
249
250#[must_use]
252pub fn trusted(root: &Path, state: &Path) -> Vec<Loaded> {
253 let dir = crate::hooks::dir(state, root);
254 load(root)
255 .into_iter()
256 .filter(|e| e.usable() && trust::state(&dir, e) == TrustState::Trusted)
257 .collect()
258}
259
260pub(crate) fn one(project: &Path, folder: &Path, source: ExtensionSource) -> Loaded {
261 let rel = folder
262 .strip_prefix(project)
263 .unwrap_or(folder)
264 .to_string_lossy()
265 .replace('\\', "/");
266 let dir_name = folder
267 .file_name()
268 .and_then(|n| n.to_str())
269 .unwrap_or_default()
270 .to_owned();
271 let mut e = Loaded {
272 name: dir_name.clone(),
273 source,
274 folder: folder.to_path_buf(),
275 rel,
276 version: None,
277 description: None,
278 origin: None,
279 digest: String::new(),
280 files: Vec::new(),
281 hooks: Vec::new(),
282 views: Vec::new(),
283 skills: Vec::new(),
284 servers: Vec::new(),
285 commands: Vec::new(),
286 offers: Vec::new(),
287 problems: Vec::new(),
288 };
289 match digest(folder) {
290 Ok((d, files)) => {
291 e.digest = d;
292 e.files = files;
293 }
294 Err(p) => {
295 e.problems.push(p);
296 return e;
297 }
298 }
299 match source {
300 ExtensionSource::AgentPlugins => plugin(&mut e, &dir_name),
301 ExtensionSource::SpecKit => {
302 spec_kit(&mut e);
303 installed_skills(&mut e, project);
304 }
305 }
306 speccoder_part(&mut e);
307 check_paths(&mut e);
308 e
309}
310
311const LOADER_VARS: &[&str] = &[
314 "PATH",
315 "NODE_OPTIONS",
316 "NODE_PATH",
317 "PYTHONPATH",
318 "PYTHONHOME",
319 "PYTHONSTARTUP",
320 "RUBYOPT",
321 "RUBYLIB",
322 "PERL5OPT",
323 "PERL5LIB",
324 "BASH_ENV",
325 "ENV",
326 "JAVA_TOOL_OPTIONS",
327];
328
329fn escapes(arg: &str) -> bool {
331 arg.split(['/', '\\']).any(|c| c == ".." || c == ".git")
332}
333
334fn check_paths(e: &mut Loaded) {
338 let own = |p: &str| {
339 let rel = p.trim_start_matches("./");
340 e.files.iter().any(|f| f.path == rel)
341 };
342 let mut problems = Vec::new();
343 let check = |what: &str, argv: &[String], problems: &mut Vec<String>| {
344 if let Some(a) = argv.iter().find(|a| escapes(a)) {
345 problems.push(format!("{what}: `{a}` leaves the extension's folder"));
346 }
347 if let Some(p) = argv.first().filter(|p| p.starts_with("./"))
348 && !own(p)
349 {
350 problems.push(format!(
351 "{what}: runs `{p}`, which is not one of its committed files"
352 ));
353 }
354 };
355 for h in &e.hooks {
356 if let Some(argv) = h.argv() {
357 check(&format!("[hooks.{}]", h.name), argv, &mut problems);
358 }
359 }
360 for v in &e.views {
361 if let ViewSource::Run(argv) = &v.source {
362 check(&format!("[views.{}]", v.name), argv, &mut problems);
363 }
364 }
365 for s in &e.servers {
366 check(
367 &format!("mcp.json server {}", s.name),
368 &s.command,
369 &mut problems,
370 );
371 if s.cwd.as_deref().is_some_and(escapes) {
372 problems.push(format!(
373 "mcp.json server {}: its cwd leaves the extension's folder",
374 s.name
375 ));
376 }
377 if let Some((k, _)) = s.env.iter().find(|(k, _)| {
378 LOADER_VARS.contains(&k.as_str()) || k.starts_with("LD_") || k.starts_with("DYLD_")
379 }) {
380 problems.push(format!(
381 "mcp.json server {}: it may not set {k} (it decides what runs)",
382 s.name
383 ));
384 }
385 if !crate::mcp::valid_server_name(&s.name) {
386 problems.push(format!(
387 "mcp.json server {}: not a valid server name (lowercase letters, digits, single `_`)",
388 s.name
389 ));
390 }
391 }
392 e.problems.extend(problems);
393}
394
395fn plugin(e: &mut Loaded, dir_name: &str) {
397 let text = match std::fs::read_to_string(e.folder.join("plugin.json")) {
398 Ok(t) => t,
399 Err(_) => {
400 e.problems
401 .push("no plugin.json (an Agent Plugins 1.0 folder starts with one)".to_owned());
402 return;
403 }
404 };
405 let manifest: serde_json::Value = match serde_json::from_str(&text) {
406 Ok(v) => v,
407 Err(err) => {
408 e.problems.push(format!("plugin.json: {err}"));
409 return;
410 }
411 };
412 let Some(m) = manifest.as_object() else {
413 e.problems.push("plugin.json is not an object".to_owned());
414 return;
415 };
416 let name = m
417 .get("name")
418 .and_then(serde_json::Value::as_str)
419 .unwrap_or_default();
420 if !core::valid_name(name) {
421 e.problems.push(format!(
422 "plugin.json: `name` \"{name}\" is not a valid name (1–64 of a-z, 0-9, `-`, `.`)"
423 ));
424 return;
425 }
426 if name != dir_name {
427 e.problems.push(format!(
428 "plugin.json names `{name}`, but the folder is `{dir_name}`: they must match"
429 ));
430 return;
431 }
432 for (k, v) in m {
433 let is_string = v.is_string();
434 let bad = match k.as_str() {
435 "version" | "description" | "homepage" | "repository" | "license" | "$schema" => {
436 !is_string
437 }
438 "keywords" => !v
439 .as_array()
440 .is_some_and(|a| a.iter().all(serde_json::Value::is_string)),
441 _ => false,
442 };
443 if bad {
444 e.problems
445 .push(format!("plugin.json: `{k}` has the wrong type"));
446 }
447 }
448 debug_assert!(PLUGIN_KEYS.contains(&"name"));
451 e.name = name.to_owned();
452 e.version = m.get("version").and_then(|v| v.as_str()).map(str::to_owned);
453 e.description = m
454 .get("description")
455 .and_then(|v| v.as_str())
456 .map(str::to_owned);
457 let mut skills: Vec<(String, PathBuf)> = std::fs::read_dir(e.folder.join("skills"))
459 .into_iter()
460 .flatten()
461 .flatten()
462 .filter_map(|d| {
463 let file = d.path().join("SKILL.md");
464 let skill = d.file_name().to_str()?.to_owned();
465 file.is_file().then(|| (format!("{name}:{skill}"), file))
466 })
467 .collect();
468 skills.sort();
469 e.skills = skills;
470 if let Ok(text) = std::fs::read_to_string(e.folder.join("mcp.json")) {
471 mcp(e, &text);
472 }
473}
474
475fn mcp(e: &mut Loaded, text: &str) {
477 let v: serde_json::Value = match serde_json::from_str(text) {
478 Ok(v) => v,
479 Err(err) => {
480 e.problems.push(format!("mcp.json: {err}"));
481 return;
482 }
483 };
484 let Some(servers) = v.get("mcpServers").and_then(|s| s.as_object()) else {
485 e.problems.push("mcp.json: no `mcpServers`".to_owned());
486 return;
487 };
488 let placeholders_ok = |s: &str| {
489 let mut rest = s;
490 while let Some(i) = rest.find("${") {
491 let after = &rest[i + 2..];
492 let Some(end) = after.find('}') else {
493 return false;
494 };
495 if !matches!(&after[..end], "PLUGIN_ROOT" | "PLUGIN_DATA") {
496 return false;
497 }
498 rest = &after[end + 1..];
499 }
500 true
501 };
502 for (server, s) in servers {
503 let at = |m: &str| format!("mcp.json server `{server}`: {m}");
504 match s.get("type").and_then(|t| t.as_str()) {
505 Some("stdio") => {}
506 Some(t @ ("streamable-http" | "sse")) => {
507 e.problems.push(at(&format!(
508 "`{t}` servers are not supported by this version (stdio only)"
509 )));
510 continue;
511 }
512 _ => {
513 e.problems
514 .push(at("`type` is stdio, streamable-http or sse"));
515 continue;
516 }
517 }
518 let command = s
519 .get("command")
520 .and_then(|c| c.as_str())
521 .unwrap_or_default();
522 let bare = !command.is_empty()
523 && !command.contains("${")
524 && (command.starts_with("./") || !command.contains(['/', '\\']));
525 if !bare {
526 e.problems.push(at(
527 "`command` is a bare program name or a `./` path, without placeholders",
528 ));
529 continue;
530 }
531 let args: Vec<String> = s
532 .get("args")
533 .and_then(|a| a.as_array())
534 .map(|a| {
535 a.iter()
536 .filter_map(|x| x.as_str().map(str::to_owned))
537 .collect()
538 })
539 .unwrap_or_default();
540 let env: Vec<(String, String)> = s
541 .get("env")
542 .and_then(|a| a.as_object())
543 .map(|o| {
544 o.iter()
545 .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_owned())))
546 .collect()
547 })
548 .unwrap_or_default();
549 let cwd = s.get("cwd").and_then(|c| c.as_str()).map(str::to_owned);
550 let all_ok = args.iter().all(|a| placeholders_ok(a))
551 && env
552 .iter()
553 .all(|(k, v)| placeholders_ok(v) && k != "PLUGIN_ROOT" && k != "PLUGIN_DATA")
554 && cwd.as_deref().is_none_or(|c| {
555 placeholders_ok(c)
556 && (c.starts_with("./")
557 || c.starts_with("${PLUGIN_ROOT}")
558 || c.starts_with("${PLUGIN_DATA}"))
559 });
560 if !all_ok {
561 e.problems.push(at(
562 "only ${PLUGIN_ROOT} and ${PLUGIN_DATA} may appear in args, env and cwd",
563 ));
564 continue;
565 }
566 let mut argv = vec![command.to_owned()];
567 argv.extend(args);
568 e.servers.push(Server {
569 name: core::server_name(&e.name, server),
570 command: argv,
571 env,
572 cwd,
573 });
574 }
575}
576
577fn spec_kit(e: &mut Loaded) {
579 match crate::format::speckit::extensions::manifest(&e.folder) {
580 Ok(m) => {
581 if let Some(id) = m.id {
582 e.name = id;
583 }
584 e.version = m.version;
585 e.description = m.description;
586 e.commands = m.commands;
587 e.offers = m
588 .hooks
589 .into_iter()
590 .map(|(event, command, prompt, mandatory)| Offer {
591 event,
592 command,
593 prompt,
594 mandatory,
595 })
596 .collect();
597 }
598 Err(err) => e.problems.push(err),
599 }
600}
601
602fn installed_skills(e: &mut Loaded, project: &Path) {
605 let mut extra = Vec::new();
606 for c in &e.commands {
607 let skill = crate::format::speckit::extensions::skill_of(c);
608 for base in crate::skills::DIRS {
609 let rel = format!("{base}/{skill}/SKILL.md");
610 let path = project.join(&rel);
611 let Ok(meta) = std::fs::symlink_metadata(&path) else {
612 continue;
613 };
614 if !meta.file_type().is_file() || meta.len() > core::MAX_BYTES {
615 e.problems
616 .push(format!("{rel} is not a regular file of a sane size"));
617 continue;
618 }
619 let bytes = std::fs::read(&path).unwrap_or_default();
620 extra.push(HookFile {
621 path: rel,
622 digest: format!("sha256:{}", sha256_hex(&bytes)),
623 });
624 }
625 }
626 if extra.is_empty() {
627 return;
628 }
629 e.files.extend(extra);
630 let mut all = Vec::new();
631 for f in &e.files {
632 all.extend_from_slice(f.path.as_bytes());
633 all.push(0);
634 all.extend_from_slice(f.digest.as_bytes());
635 all.push(0);
636 }
637 e.digest = format!("sha256:{}", sha256_hex(&all));
638}
639
640fn speccoder_part(e: &mut Loaded) {
642 let part = e.folder.join(NAMESPACE);
643 if let Ok(text) = std::fs::read_to_string(part.join("source.toml"))
644 && let Ok(origin) = toml::from_str::<AddedFrom>(&text)
645 {
646 e.origin = Some(origin);
647 }
648 let Ok(text) = std::fs::read_to_string(part.join("extension.toml")) else {
649 return;
650 };
651 let table: toml::Table = match toml::from_str(&text) {
652 Ok(t) => t,
653 Err(err) => {
654 e.problems
655 .push(format!("{NAMESPACE}/extension.toml: {err}"));
656 return;
657 }
658 };
659 for (key, value) in &table {
660 let Some(entries) = value.as_table() else {
661 e.problems.push(format!(
662 "{NAMESPACE}/extension.toml: `{key}` is not a table (known: hooks, views)"
663 ));
664 continue;
665 };
666 for (name, t) in entries {
667 let Ok(json) = serde_json::to_value(t) else {
668 continue;
669 };
670 match key.as_str() {
671 "hooks" => match speccoder_core::hooks::parse(name, &json) {
672 Ok(mut def) => {
673 def.name = format!("{}.{name}", e.name);
674 def.extension =
675 Some((e.name.clone(), e.folder.to_string_lossy().into_owned()));
676 e.hooks.push(def);
677 }
678 Err(err) => e.problems.push(err),
679 },
680 "views" => match core::parse_view(name, &json) {
681 Ok(v) => e.views.push(v),
682 Err(err) => e.problems.push(err),
683 },
684 other => e.problems.push(format!(
685 "{NAMESPACE}/extension.toml: unknown table `[{other}]` (known: hooks, views)"
686 )),
687 }
688 }
689 }
690}
691
692fn sha256_hex(bytes: &[u8]) -> String {
693 Sha256::digest(bytes)
694 .iter()
695 .map(|b| format!("{b:02x}"))
696 .collect()
697}
698
699pub fn digest(folder: &Path) -> Result<(String, Vec<HookFile>), String> {
711 let meta =
712 std::fs::symlink_metadata(folder).map_err(|e| format!("{}: {e}", folder.display()))?;
713 if meta.file_type().is_symlink() {
714 return Err(
715 "the extension's folder is a link: an extension is a folder of its own".to_owned(),
716 );
717 }
718 let listed = match git_files(folder)? {
719 Some(files) => files,
720 None => walk(folder, false)?,
721 };
722 if listed.len() > core::MAX_FILES {
723 return Err(format!(
724 "more than {} files: an extension holds at most that many",
725 core::MAX_FILES
726 ));
727 }
728 let mut files = Vec::new();
729 let mut bytes_total: u64 = 0;
730 for rel in listed {
731 let path = folder.join(&rel);
732 let meta = std::fs::symlink_metadata(&path).map_err(|e| format!("{rel}: {e}"))?;
733 if meta.file_type().is_symlink() {
734 return Err(format!("{rel} is a link: an extension holds files only"));
735 }
736 if !meta.file_type().is_file() {
737 return Err(format!("{rel} is not a regular file"));
738 }
739 bytes_total += meta.len();
740 if bytes_total > core::MAX_BYTES {
741 return Err(format!(
742 "more than {} MB: an extension holds at most that much",
743 core::MAX_BYTES / (1024 * 1024)
744 ));
745 }
746 let mut hasher = Sha256::new();
747 let mut f = std::fs::File::open(&path).map_err(|e| format!("{rel}: {e}"))?;
748 let mut chunk = [0u8; 64 * 1024];
749 loop {
750 let n = std::io::Read::read(&mut f, &mut chunk).map_err(|e| format!("{rel}: {e}"))?;
751 if n == 0 {
752 break;
753 }
754 hasher.update(&chunk[..n]);
755 }
756 let hex: String = hasher
757 .finalize()
758 .iter()
759 .map(|b| format!("{b:02x}"))
760 .collect();
761 files.push(HookFile {
762 path: rel,
763 digest: format!("sha256:{hex}"),
764 });
765 }
766 files.sort_by(|a, b| a.path.cmp(&b.path));
767 let mut all = Vec::new();
768 for f in &files {
769 all.extend_from_slice(f.path.as_bytes());
770 all.push(0);
771 all.extend_from_slice(f.digest.as_bytes());
772 all.push(0);
773 }
774 Ok((format!("sha256:{}", sha256_hex(&all)), files))
775}
776
777fn git_files(folder: &Path) -> Result<Option<Vec<String>>, String> {
780 let git = |args: &[&str]| {
781 std::process::Command::new("git")
782 .args(["-c", "core.quotePath=off"])
783 .args(args)
784 .current_dir(folder)
785 .env("GIT_CONFIG_NOSYSTEM", "1")
786 .output()
787 .ok()
788 .filter(|o| o.status.success())
789 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
790 };
791 let Some(top) = git(&["rev-parse", "--show-toplevel"]) else {
792 return Ok(None);
793 };
794 if top.trim().is_empty() {
795 return Ok(None);
796 }
797 let names = |text: String| -> Vec<String> {
798 text.split('\0')
799 .filter(|s| !s.is_empty())
800 .map(str::to_owned)
801 .collect()
802 };
803 let loose = names(
804 git(&[
805 "ls-files",
806 "-z",
807 "--others",
808 "--exclude-standard",
809 "--",
810 ".",
811 ])
812 .unwrap_or_default(),
813 );
814 let changed = names(
815 git(&["diff", "--name-only", "-z", "--relative", "HEAD", "--", "."]).unwrap_or_default(),
816 );
817 let ignored = names(
818 git(&[
819 "ls-files",
820 "-z",
821 "--others",
822 "--ignored",
823 "--exclude-standard",
824 "--directory",
825 "--",
826 ".",
827 ])
828 .unwrap_or_default(),
829 );
830 let mut pending: Vec<String> = loose.into_iter().chain(changed).collect();
831 pending.sort();
832 pending.dedup();
833 if !pending.is_empty() {
834 return Err(format!(
835 "has changes not committed ({}): commit them — a run uses what is committed",
836 pending.join(", ")
837 ));
838 }
839 if !ignored.is_empty() {
840 return Err(format!(
841 "holds files git ignores ({}): they would run without being trusted — delete them",
842 ignored.join(", ")
843 ));
844 }
845 let tracked = names(git(&["ls-files", "-z", "--", "."]).unwrap_or_default());
846 if tracked.is_empty() {
847 return Err("is not committed: commit it — a run uses what is committed".to_owned());
848 }
849 Ok(Some(tracked))
850}
851
852pub(crate) fn walk(folder: &Path, skip_git: bool) -> Result<Vec<String>, String> {
855 let mut out = Vec::new();
856 let mut stack = vec![folder.to_path_buf()];
857 let mut entries = 0usize;
858 while let Some(dir) = stack.pop() {
859 for entry in std::fs::read_dir(&dir)
860 .map_err(|e| format!("{}: {e}", dir.display()))?
861 .flatten()
862 {
863 entries += 1;
864 if entries > core::MAX_FILES * 2 {
865 return Err(format!(
866 "more than {} files: an extension holds at most that many",
867 core::MAX_FILES
868 ));
869 }
870 let path = entry.path();
871 let rel = path
872 .strip_prefix(folder)
873 .unwrap_or(&path)
874 .to_string_lossy()
875 .replace('\\', "/");
876 let kind = entry.file_type().map_err(|e| format!("{rel}: {e}"))?;
877 if kind.is_symlink() {
878 return Err(format!("{rel} is a link: an extension holds files only"));
879 }
880 if kind.is_dir() {
881 if path.file_name().is_some_and(|n| n == ".git") {
882 if skip_git {
883 continue;
884 }
885 return Err(
886 ".git inside an extension: add it from its repository instead".to_owned(),
887 );
888 }
889 stack.push(path);
890 } else {
891 out.push(rel);
892 }
893 }
894 }
895 Ok(out)
896}
897
898#[must_use]
900pub fn feature_dir(root: &Path, feature: &str) -> Option<PathBuf> {
901 let opened = crate::format::open(root, Some(feature)).ok()?;
902 let f = opened
903 .snapshot
904 .features
905 .iter()
906 .find(|f| f.name == feature)?;
907 Some(Path::new(&opened.snapshot.root).join(&f.dir))
908}
909
910#[must_use]
914pub fn resolve(argv: &[String], folder: Option<&Path>, vars: &[(&str, String)]) -> Vec<String> {
915 argv.iter()
916 .enumerate()
917 .map(|(i, a)| {
918 let mut a = a.clone();
919 for (k, v) in vars {
920 a = a.replace(&format!("${{{k}}}"), v);
921 }
922 match folder {
923 Some(f)
924 if a.starts_with("./")
925 && !escapes(&a)
926 && (i == 0 || f.join(&a[2..]).exists()) =>
927 {
928 f.join(&a[2..]).to_string_lossy().into_owned()
929 }
930 _ => a,
931 }
932 })
933 .collect()
934}
935
936#[must_use]
940pub fn relative_to(project: &Path, path: &str) -> Option<String> {
941 let p = Path::new(path);
942 let rel = if p.is_absolute() {
943 let canonical = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
944 p.strip_prefix(&canonical)
945 .or_else(|_| p.strip_prefix(project))
946 .ok()?
947 .to_path_buf()
948 } else {
949 p.to_path_buf()
950 };
951 let text = rel.to_string_lossy().replace('\\', "/");
952 (!text.is_empty() && !text.split('/').any(|s| s == "..")).then_some(text)
953}
954
955#[must_use]
960pub fn servers(root: &Path, state: &Path) -> Vec<crate::mcp::ServerConfig> {
961 let base = crate::worktree::project_state_dir(state, root).join("extensions/data");
962 let mut out = Vec::new();
963 for e in trusted(root, state) {
964 let data = base.join(&e.name);
965 let _ = std::fs::create_dir_all(&data);
966 let vars = [
967 ("PLUGIN_ROOT", e.folder.to_string_lossy().into_owned()),
968 ("PLUGIN_DATA", data.to_string_lossy().into_owned()),
969 ];
970 for s in &e.servers {
971 let expand = |v: &str| {
972 vars.iter()
973 .fold(v.to_owned(), |a, (k, x)| a.replace(&format!("${{{k}}}"), x))
974 };
975 let mut command = resolve(&s.command[..1], Some(&e.folder), &[]);
976 command.extend(s.command[1..].iter().map(|a| expand(a)));
977 out.push(crate::mcp::ServerConfig {
978 name: s.name.clone(),
979 command,
980 allow: Vec::new(),
981 deny: Vec::new(),
982 env: s.env.iter().map(|(k, v)| (k.clone(), expand(v))).collect(),
983 cwd: s.cwd.as_deref().map(|c| {
985 let c = expand(c);
986 c.strip_prefix("./")
987 .map_or_else(|| PathBuf::from(&c), |r| e.folder.join(r))
988 }),
989 data: Some(data.clone()),
990 });
991 }
992 }
993 out
994}
995
996#[must_use]
999pub fn offered_at(event: &str) -> &'static [&'static str] {
1000 match event {
1001 "after_specify" | "after_clarify" | "before_plan" => &["plan"],
1002 "after_plan" | "before_tasks" => &["tasks"],
1003 "after_tasks" | "before_implement" => &["commit", "run"],
1004 "after_implement" | "before_converge" | "after_converge" => &["review"],
1005 _ => &[],
1006 }
1007}