1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use agentplane::model::ModelProvider;
12use speccoder_core::{
13 CommandData, CommandResult, CommandStatus, SkillEntry, SkillRunReport, SkillsReport,
14};
15
16use crate::format::Planning;
17use crate::{RunOptions, progress, redact, skills, substrate, tools, verify, worktree};
18
19fn root_of(path: &Path) -> Result<PathBuf, Box<CommandResult>> {
20 match crate::format::open(path, None) {
21 Ok(opened) => Ok(PathBuf::from(&opened.snapshot.root)),
22 Err(r) if r.unmarked => Err(Box::new(CommandResult::new(
23 "skill",
24 CommandStatus::NotAProject,
25 crate::format::reference().messages().no_project,
26 ))),
27 Err(r) => Err(Box::new(CommandResult::new("skill", r.status, &r.message))),
28 }
29}
30
31#[must_use]
33pub fn list(path: &Path) -> CommandResult {
34 let root = match root_of(path) {
35 Ok(r) => r,
36 Err(r) => return *r,
37 };
38 let format = crate::format::at(&root);
39 let found = skills::discover_with(&root, format.planning());
40 let message = if found.is_empty() {
41 format.messages().no_skills.to_owned()
42 } else {
43 format!(
44 "{} skill(s); run one with `speccoder spec <name> [arguments]`",
45 found.len()
46 )
47 };
48 CommandResult::new("skill", CommandStatus::Ok, &message).with_data(CommandData::Skills(
49 Box::new(SkillsReport {
50 skills: found
51 .into_iter()
52 .map(|s| SkillEntry {
53 name: s.name,
54 description: s.description,
55 path: s.path,
56 refused: s.refused,
57 })
58 .collect(),
59 }),
60 ))
61}
62
63pub fn run_with(
65 path: &Path,
66 name: &str,
67 arguments: &str,
68 state: &Path,
69 provider: Arc<dyn ModelProvider>,
70 secret: Option<&str>,
71 options: &RunOptions,
72) -> CommandResult {
73 run_in(
74 path, None, name, arguments, state, provider, secret, options,
75 )
76}
77
78#[allow(clippy::too_many_arguments)]
86pub fn run_in(
87 path: &Path,
88 feature: Option<&str>,
89 name: &str,
90 arguments: &str,
91 state: &Path,
92 provider: Arc<dyn ModelProvider>,
93 secret: Option<&str>,
94 options: &RunOptions,
95) -> CommandResult {
96 let reviews = crate::review::snapshot(path);
97 let result = run_unguarded(
98 path, feature, name, arguments, state, provider, secret, options, None,
99 );
100 let restored = crate::review::restore(&reviews);
101 if restored.is_empty() {
102 return result;
103 }
104 let message = format!(
105 "{}; undone: the skill changed the person's review ({}) — only `speccoder review` writes it",
106 result.message(),
107 restored.join(", ")
108 );
109 let status = result.status();
110 result.reworded(status, &message)
111}
112
113#[allow(clippy::too_many_arguments)]
116pub(crate) fn run_scoped(
117 path: &Path,
118 name: &str,
119 arguments: &str,
120 state: &Path,
121 provider: Arc<dyn ModelProvider>,
122 secret: Option<&str>,
123 options: &RunOptions,
124 rules: &'static crate::format::Rules,
125) -> CommandResult {
126 let reviews = crate::review::snapshot(path);
127 let result = run_unguarded(
128 path,
129 None,
130 name,
131 arguments,
132 state,
133 provider,
134 secret,
135 options,
136 Some(rules),
137 );
138 let _ = crate::review::restore(&reviews);
139 result
140}
141
142#[allow(clippy::too_many_arguments)]
143fn run_unguarded(
144 path: &Path,
145 feature: Option<&str>,
146 name: &str,
147 arguments: &str,
148 state: &Path,
149 provider: Arc<dyn ModelProvider>,
150 secret: Option<&str>,
151 options: &RunOptions,
152 scoped: Option<&'static crate::format::Rules>,
153) -> CommandResult {
154 let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
155 let s = match crate::setup(path, feature, state, "skill") {
156 Ok(s) => s,
157 Err(r) => return *r,
158 };
159 let root = PathBuf::from(&s.snapshot.root);
160 let planning = s.format.planning();
161 let installed = skills::discover_with(&root, planning);
162 let Some(skill) = installed
163 .iter()
164 .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
165 else {
166 return refuse(
167 CommandStatus::NotFound,
168 format!("no skill `{name}` (see `speccoder spec`)"),
169 );
170 };
171 if let Some(why) = &skill.refused {
172 return refuse(
173 CommandStatus::UsageError,
174 format!("`{}` is not hosted: {why}", skill.name),
175 );
176 }
177 if !skill.invocable {
178 return refuse(
179 CommandStatus::UsageError,
180 format!("`{}` is marked user-invocable: false", skill.name),
181 );
182 }
183 let event = core_event(planning, &skill.name).map(str::to_owned);
185 let mut hook_lines = Vec::new();
186 if let Some(e) = &event {
187 match run_hooks_in(
188 &format!("before_{e}"),
189 arguments,
190 path,
191 feature,
192 state,
193 &provider,
194 secret,
195 options,
196 ) {
197 Ok(lines) => hook_lines.extend(lines),
198 Err(stop) => return *stop,
199 }
200 }
201 let instruction = match skills::instruction(&root, skill, arguments) {
202 Ok(i) => i,
203 Err(e) => return refuse(CommandStatus::Error, e),
204 };
205 let config_text = verify::find_config(&root)
206 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
207 .unwrap_or_default();
208 let budget = match substrate::run_config::parse(&config_text) {
209 Ok(c) => c,
210 Err(e) => {
211 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
212 .with_code("CONFIG-INVALID");
213 }
214 };
215
216 let tmp = s.plane_dir.join("tmp");
217 let before = match worktree::checkpoint(&root, &tmp) {
218 Ok(c) => c,
219 Err(e) => {
220 return refuse(
221 CommandStatus::GitPrecondition,
222 format!(
223 "a hosted skill needs a git repository with a commit: {}",
224 e.0
225 ),
226 );
227 }
228 };
229 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
230 &s.plane_dir,
231 s.model.prices,
232 options.progress.clone(),
233 ));
234 live.event(&progress::Event::TaskStarted {
235 feature: "skill".to_owned(),
236 task: skill.name.clone(),
237 title: skill.description.clone(),
238 });
239 let rt = match crate::tokio_runtime() {
240 Ok(rt) => rt,
241 Err(e) => return refuse(CommandStatus::RunFailed, e),
242 };
243 let runner = match crate::sandbox::runner_at(&root) {
245 Ok((r, _)) => r,
246 Err(e) => return refuse(CommandStatus::ConfigError, e),
247 };
248 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
249 let short = skill
254 .name
255 .strip_prefix(planning.prefix)
256 .unwrap_or(&skill.name);
257 let creates = planning.creates.contains(&short);
258 let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
259 let env: Vec<(String, String)> = match (planning.current_env, &selected) {
260 (Some(var), Some(dir)) if !creates => {
261 vec![(var.to_owned(), canonical.join(dir).display().to_string())]
262 }
263 _ => Vec::new(),
264 };
265 let server = match tools::WorkspaceTools::new(
266 &root,
267 tools::policy::Policy::new(&[], &[])
268 .rules(scoped.unwrap_or_else(|| s.format.rules()))
269 .authoring(),
270 ) {
271 Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
272 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
273 };
274 let mut input = substrate::host::input(
275 &skill.name,
276 &instruction,
277 &canonical.display().to_string(),
278 (budget.max_turns, budget.max_tool_calls),
279 options.approver.is_some(),
280 );
281 if scoped.is_some() {
282 input["settings"]["scope"] = serde_json::json!("bug");
283 }
284 let outcome = rt.block_on(async {
285 let plane = substrate::Plane::open_with(
286 &s.plane_dir,
287 &s.model,
288 Arc::clone(&provider),
289 Some(server),
290 substrate::Hooks {
291 progress: Some(Arc::clone(&live)),
292 format: Some(s.format),
293 ..substrate::Hooks::default()
294 },
295 )?;
296 let outcome = plane
298 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
299 .await?;
300 let context = crate::approvals::PresentContext {
303 root: Some(canonical.clone()),
304 ..crate::approvals::PresentContext::default()
305 };
306 plane
307 .decide_inline(outcome, options.approver.as_deref(), &context)
308 .await
309 });
310 let outcome = outcome.map(|i| {
313 let _ = crate::actions::allow_always_inline(
314 &root,
315 &i.always.first().map_or_else(String::new, |a| {
316 a.task.split('/').next().unwrap_or_default().to_owned()
317 }),
318 &i.always,
319 state,
320 );
321 i.outcome
322 });
323 let outcome = match outcome {
324 Ok(o) => o,
325 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
326 };
327 let output = outcome
328 .output
329 .as_ref()
330 .map(|o| o.peek().clone())
331 .unwrap_or(serde_json::Value::Null);
332 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
333 let status = if succeeded {
334 output["status"].as_str().unwrap_or("failed").to_owned()
335 } else {
336 "failed".to_owned()
337 };
338 let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
339 .and_then(|after| worktree::diff(&root, &before, &after))
340 .unwrap_or_default()
341 .into_iter()
342 .map(|c| {
343 let kind = match c.kind {
344 worktree::ChangeKind::Added => "added",
345 worktree::ChangeKind::Modified => "modified",
346 worktree::ChangeKind::Deleted => "deleted",
347 };
348 (c.path, kind.to_owned())
349 })
350 .collect();
351 let report = SkillRunReport {
352 skill: skill.name.clone(),
353 run_id: outcome.run_id.to_string(),
354 summary: output["report"]["summary"].as_str().map(str::to_owned),
355 questions: output["report"]["questions"]
356 .as_array()
357 .map(|a| {
358 a.iter()
359 .filter_map(|q| q.as_str().map(str::to_owned))
360 .collect()
361 })
362 .unwrap_or_default(),
363 changes,
364 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
365 pending: output.get("pending").cloned(),
366 reason: output["reason"]
367 .as_str()
368 .map(str::to_owned)
369 .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
370 usage: output.get("usage").cloned(),
371 status: status.clone(),
372 hooks: Vec::new(),
373 current: None,
374 };
375 let mut report = report;
378 if creates && succeeded {
379 let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
380 let made = s
381 .format
382 .inspect(&root, None)
383 .map(|after| after.features)
384 .unwrap_or_default()
385 .into_iter()
386 .rfind(|f| !known.contains(&f.dir.as_str()));
387 if let Some(f) = made {
388 match s.format.set_current(&root, Some(&f.dir)) {
389 Ok(pointer) => {
390 report.current = Some(format!("{} (recorded in {pointer})", f.name));
391 }
392 Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
393 }
394 }
395 } else if let Some(f) = s.snapshot.feature.as_ref() {
396 report.current = Some(format!(
397 "{} ({})",
398 f.feature.name,
399 crate::current::reason_text(s.snapshot.selection.reason, s.format)
400 ));
401 }
402 let tokens = |k: &str| {
403 report
404 .usage
405 .as_ref()
406 .and_then(|u| u[k].as_u64())
407 .unwrap_or(0)
408 };
409 let (code, message) = match status.as_str() {
410 "completed" => (
411 CommandStatus::Ok,
412 format!(
413 "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
414 skill.name,
415 report.changes.len()
416 ),
417 ),
418 "failed" => (
419 CommandStatus::RunFailed,
420 format!(
421 "{} failed: {}",
422 skill.name,
423 report.reason.clone().unwrap_or_default()
424 ),
425 ),
426 other => (
427 CommandStatus::NeedsHuman,
428 format!(
429 "{} stopped ({other}): {}",
430 skill.name,
431 report
432 .reason
433 .clone()
434 .filter(|r| !r.is_empty())
435 .or_else(|| {
436 (!report.questions.is_empty())
437 .then(|| format!("open questions: {}", report.questions.join(" | ")))
438 })
439 .or_else(|| report
440 .pending
441 .as_ref()
442 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
443 .unwrap_or_default()
444 ),
445 ),
446 };
447 live.event(&progress::Event::TaskFinished {
448 task: skill.name.clone(),
449 outcome: status,
450 message: redact(&message, secret),
451 run: None,
452 input_tokens: tokens("input_tokens"),
453 output_tokens: tokens("output_tokens"),
454 cache_read_tokens: tokens("cache_read_tokens"),
455 cache_write_tokens: tokens("cache_write_tokens"),
456 usd: None,
457 });
458 if code == CommandStatus::Ok
459 && let Some(e) = &event
460 {
461 match run_hooks_in(
462 &format!("after_{e}"),
463 arguments,
464 path,
465 feature,
466 state,
467 &provider,
468 secret,
469 options,
470 ) {
471 Ok(lines) => hook_lines.extend(lines),
472 Err(stop) => {
473 report.hooks = hook_lines;
474 return CommandResult::carried("skill", &stop)
475 .with_data(CommandData::Skill(Box::new(report)));
476 }
477 }
478 }
479 report.hooks = hook_lines;
480 CommandResult::new("skill", code, &redact(&message, secret))
481 .with_data(CommandData::Skill(Box::new(report)))
482}
483
484#[must_use]
486pub fn run(
487 path: &Path,
488 feature: Option<&str>,
489 name: &str,
490 arguments: &str,
491 options: &RunOptions,
492) -> CommandResult {
493 crate::with_driver("skill", path, None, |state, provider, key| {
494 run_in(
495 path,
496 feature,
497 name,
498 arguments,
499 state,
500 Arc::clone(provider),
501 Some(key),
502 options,
503 )
504 })
505}
506
507pub fn run_hooks(
511 event: &str,
512 arguments: &str,
513 path: &Path,
514 state: &Path,
515 provider: &Arc<dyn ModelProvider>,
516 secret: Option<&str>,
517 options: &RunOptions,
518) -> Result<Vec<String>, Box<CommandResult>> {
519 run_hooks_in(
520 event, arguments, path, None, state, provider, secret, options,
521 )
522}
523
524#[allow(clippy::too_many_arguments)]
526pub fn run_hooks_in(
527 event: &str,
528 arguments: &str,
529 path: &Path,
530 feature: Option<&str>,
531 state: &Path,
532 provider: &Arc<dyn ModelProvider>,
533 secret: Option<&str>,
534 options: &RunOptions,
535) -> Result<Vec<String>, Box<CommandResult>> {
536 let root = root_of(path)?;
537 let plan = crate::hooks::plan(&root, event)
538 .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
539 let mut lines: Vec<String> = Vec::new();
540 for hook in &plan.run {
541 let note = |text: String| {
542 if let Some(p) = &options.progress {
543 p.event(&progress::Event::Notice { text });
544 }
545 };
546 note(format!(
547 "{event} hook ({}): {}",
548 hook.extension, hook.command
549 ));
550 let res = run_in(
551 path,
552 feature,
553 &hook.skill(),
554 arguments,
555 state,
556 Arc::clone(provider),
557 secret,
558 options,
559 );
560 if res.status() != CommandStatus::Ok {
561 let status = if res.status() == CommandStatus::NotFound {
562 CommandStatus::ConfigError
563 } else {
564 res.status()
565 };
566 return Err(Box::new(CommandResult::new(
567 "hooks",
568 status,
569 &format!(
570 "mandatory {event} hook `{}` ({}) did not complete: {}",
571 hook.command,
572 hook.extension,
573 res.message()
574 ),
575 )));
576 }
577 lines.push(format!(
578 "ran {event} hook {} ({})",
579 hook.command, hook.extension
580 ));
581 }
582 for hook in &plan.offer {
583 let line = crate::hooks::offer_line(event, hook);
584 if let Some(p) = &options.progress {
585 p.event(&progress::Event::Notice { text: line.clone() });
586 }
587 lines.push(line);
588 }
589 for (hook, why) in &plan.skipped {
590 lines.push(format!(
591 "skipped {event} hook {} ({}): {why}",
592 hook.command, hook.extension
593 ));
594 }
595 Ok(lines)
596}
597
598fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
600 let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
601 planning.core.contains(&short).then_some(short)
602}
603
604#[must_use]
608pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
609 let format = crate::format::at(path);
610 let Some(snap) = format.open(path, feature) else {
611 return Vec::new();
612 };
613 let Some(f) = snap.feature.as_ref() else {
614 return Vec::new();
615 };
616 let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
617 crate::git::Git::at(&top)
618 .run(&[
619 "status",
620 "--porcelain",
621 "-z",
622 "--untracked-files=all",
623 "--",
624 &dir,
625 ])
626 .map(|out| {
627 String::from_utf8_lossy(&out)
628 .split('\0')
629 .filter(|e| e.len() > 3)
630 .map(|e| e[3..].to_owned())
631 .collect()
632 })
633 .unwrap_or_default()
634}
635
636#[must_use]
640pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
641 commit_planning_as(path, feature, paths, None)
642}
643
644#[must_use]
646pub fn commit_planning_as(
647 path: &Path,
648 feature: Option<&str>,
649 paths: &[String],
650 subject: Option<&str>,
651) -> CommandResult {
652 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
653 let format = crate::format::at(path);
654 let Some(snap) = format.open(path, feature) else {
655 return refuse(
656 CommandStatus::NotAProject,
657 &(format.messages().not_a_project)(path),
658 );
659 };
660 let Some(f) = snap.feature.as_ref() else {
661 return refuse(
662 CommandStatus::SelectionNeeded,
663 "no current feature to commit for",
664 );
665 };
666 let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
667 let dir = format!("{unit}/");
668 if paths.is_empty() {
669 return refuse(CommandStatus::NotFound, "nothing to commit");
670 }
671 let inside = |p: &str| {
674 p.strip_prefix(&dir).is_some_and(|rest| {
675 rest.split('/')
676 .all(|c| !c.is_empty() && c != "." && c != "..")
677 })
678 };
679 if let Some(p) = paths.iter().find(|p| !inside(p)) {
680 return refuse(
681 CommandStatus::UsageError,
682 &format!(
683 "`{p}` is not in {}; only planning files are committed here",
684 f.feature.dir
685 ),
686 );
687 }
688 let git = crate::git::Git::at(&top);
689 let names: Vec<&str> = paths
690 .iter()
691 .map(|p| p.rsplit('/').next().unwrap_or(p))
692 .collect();
693 let message = subject.map_or_else(
694 || format!("spec({}): {}", f.feature.name, names.join(", ")),
695 |t| format!("spec({}): {t}", f.feature.name),
696 );
697 let mut add = vec!["add", "--"];
698 add.extend(paths.iter().map(String::as_str));
699 let mut commit = vec!["commit", "-q", "-m", &message, "--"];
700 commit.extend(paths.iter().map(String::as_str));
701 match git.run(&add).and_then(|_| git.run(&commit)) {
702 Ok(_) => {
703 let id = git
704 .line(&["rev-parse", "--short", "HEAD"])
705 .unwrap_or_default();
706 CommandResult::new(
707 "commit",
708 CommandStatus::Ok,
709 &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
710 )
711 }
712 Err(e) => refuse(
713 CommandStatus::GitPrecondition,
714 &format!("commit failed: {}", e.0),
715 ),
716 }
717}
718
719#[must_use]
723pub fn commit_exactly(root: &Path, paths: &[String], message: &str) -> CommandResult {
724 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
725 if paths.is_empty() {
726 return refuse(CommandStatus::NotFound, "nothing to commit");
727 }
728 let (top, _) = repo_dir(root, "");
729 let git = crate::git::Git::at(&top);
730 let mut add = vec!["add", "--"];
731 add.extend(paths.iter().map(String::as_str));
732 let mut commit = vec!["commit", "-q", "-m", message, "--"];
733 commit.extend(paths.iter().map(String::as_str));
734 match git.run(&add).and_then(|_| git.run(&commit)) {
735 Ok(_) => {
736 let id = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
737 CommandResult::new("commit", CommandStatus::Ok, &id)
738 }
739 Err(e) => refuse(
740 CommandStatus::GitPrecondition,
741 &format!("commit failed: {}", e.0),
742 ),
743 }
744}
745
746fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
749 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
750 let top = crate::git::Git::at(root)
751 .toplevel()
752 .ok()
753 .flatten()
754 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
755 let prefix = canonical(root)
756 .strip_prefix(&top)
757 .map(|p| p.to_string_lossy().replace('\\', "/"))
758 .unwrap_or_default();
759 let dir = if prefix.is_empty() {
760 unit_dir.to_owned()
761 } else {
762 format!("{prefix}/{unit_dir}")
763 };
764 (top, dir)
765}