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,
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)]
114fn run_unguarded(
115 path: &Path,
116 feature: Option<&str>,
117 name: &str,
118 arguments: &str,
119 state: &Path,
120 provider: Arc<dyn ModelProvider>,
121 secret: Option<&str>,
122 options: &RunOptions,
123) -> CommandResult {
124 let refuse = |status, m: String| CommandResult::new("skill", status, &redact(&m, secret));
125 let s = match crate::setup(path, feature, state, "skill") {
126 Ok(s) => s,
127 Err(r) => return *r,
128 };
129 let root = PathBuf::from(&s.snapshot.root);
130 let planning = s.format.planning();
131 let installed = skills::discover_with(&root, planning);
132 let Some(skill) = installed
133 .iter()
134 .find(|k| k.name == name || k.name == format!("{}{name}", planning.prefix))
135 else {
136 return refuse(
137 CommandStatus::NotFound,
138 format!("no skill `{name}` (see `speccoder spec`)"),
139 );
140 };
141 if let Some(why) = &skill.refused {
142 return refuse(
143 CommandStatus::UsageError,
144 format!("`{}` is not hosted: {why}", skill.name),
145 );
146 }
147 if !skill.invocable {
148 return refuse(
149 CommandStatus::UsageError,
150 format!("`{}` is marked user-invocable: false", skill.name),
151 );
152 }
153 let event = core_event(planning, &skill.name).map(str::to_owned);
155 let mut hook_lines = Vec::new();
156 if let Some(e) = &event {
157 match run_hooks_in(
158 &format!("before_{e}"),
159 arguments,
160 path,
161 feature,
162 state,
163 &provider,
164 secret,
165 options,
166 ) {
167 Ok(lines) => hook_lines.extend(lines),
168 Err(stop) => return *stop,
169 }
170 }
171 let instruction = match skills::instruction(&root, skill, arguments) {
172 Ok(i) => i,
173 Err(e) => return refuse(CommandStatus::Error, e),
174 };
175 let config_text = verify::find_config(&root)
176 .and_then(|d| std::fs::read_to_string(d.join(verify::config::FILE)).ok())
177 .unwrap_or_default();
178 let budget = match substrate::run_config::parse(&config_text) {
179 Ok(c) => c,
180 Err(e) => {
181 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
182 .with_code("CONFIG-INVALID");
183 }
184 };
185
186 let tmp = s.plane_dir.join("tmp");
187 let before = match worktree::checkpoint(&root, &tmp) {
188 Ok(c) => c,
189 Err(e) => {
190 return refuse(
191 CommandStatus::GitPrecondition,
192 format!(
193 "a hosted skill needs a git repository with a commit: {}",
194 e.0
195 ),
196 );
197 }
198 };
199 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
200 &s.plane_dir,
201 s.model.prices,
202 options.progress.clone(),
203 ));
204 live.event(&progress::Event::TaskStarted {
205 feature: "skill".to_owned(),
206 task: skill.name.clone(),
207 title: skill.description.clone(),
208 });
209 let rt = match crate::tokio_runtime() {
210 Ok(rt) => rt,
211 Err(e) => return refuse(CommandStatus::RunFailed, e),
212 };
213 let runner = match crate::sandbox::runner_at(&root) {
215 Ok((r, _)) => r,
216 Err(e) => return refuse(CommandStatus::ConfigError, e),
217 };
218 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
219 let short = skill
224 .name
225 .strip_prefix(planning.prefix)
226 .unwrap_or(&skill.name);
227 let creates = planning.creates.contains(&short);
228 let selected = s.snapshot.feature.as_ref().map(|f| f.feature.dir.clone());
229 let env: Vec<(String, String)> = match (planning.current_env, &selected) {
230 (Some(var), Some(dir)) if !creates => {
231 vec![(var.to_owned(), canonical.join(dir).display().to_string())]
232 }
233 _ => Vec::new(),
234 };
235 let server = match tools::WorkspaceTools::new(
236 &root,
237 tools::policy::Policy::new(&[], &[])
238 .rules(s.format.rules())
239 .authoring(),
240 ) {
241 Ok(t) => Arc::new(t.with_runner(runner).with_env(env)),
242 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
243 };
244 let input = substrate::host::input(
245 &skill.name,
246 &instruction,
247 &canonical.display().to_string(),
248 (budget.max_turns, budget.max_tool_calls),
249 options.approver.is_some(),
250 );
251 let outcome = rt.block_on(async {
252 let plane = substrate::Plane::open_with(
253 &s.plane_dir,
254 &s.model,
255 Arc::clone(&provider),
256 Some(server),
257 substrate::Hooks {
258 progress: Some(Arc::clone(&live)),
259 format: Some(s.format),
260 ..substrate::Hooks::default()
261 },
262 )?;
263 let outcome = plane
265 .skill(&skill.name, agentplane::core::Tainted::trusted(input))
266 .await?;
267 let context = crate::approvals::PresentContext {
270 root: Some(canonical.clone()),
271 ..crate::approvals::PresentContext::default()
272 };
273 plane
274 .decide_inline(outcome, options.approver.as_deref(), &context)
275 .await
276 .map(|(o, _)| o)
277 });
278 let outcome = match outcome {
279 Ok(o) => o,
280 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
281 };
282 let output = outcome
283 .output
284 .as_ref()
285 .map(|o| o.peek().clone())
286 .unwrap_or(serde_json::Value::Null);
287 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
288 let status = if succeeded {
289 output["status"].as_str().unwrap_or("failed").to_owned()
290 } else {
291 "failed".to_owned()
292 };
293 let changes: Vec<(String, String)> = worktree::checkpoint(&root, &tmp)
294 .and_then(|after| worktree::diff(&root, &before, &after))
295 .unwrap_or_default()
296 .into_iter()
297 .map(|c| {
298 let kind = match c.kind {
299 worktree::ChangeKind::Added => "added",
300 worktree::ChangeKind::Modified => "modified",
301 worktree::ChangeKind::Deleted => "deleted",
302 };
303 (c.path, kind.to_owned())
304 })
305 .collect();
306 let report = SkillRunReport {
307 skill: skill.name.clone(),
308 run_id: outcome.run_id.to_string(),
309 summary: output["report"]["summary"].as_str().map(str::to_owned),
310 questions: output["report"]["questions"]
311 .as_array()
312 .map(|a| {
313 a.iter()
314 .filter_map(|q| q.as_str().map(str::to_owned))
315 .collect()
316 })
317 .unwrap_or_default(),
318 changes,
319 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
320 pending: output.get("pending").cloned(),
321 reason: output["reason"]
322 .as_str()
323 .map(str::to_owned)
324 .or_else(|| (!succeeded).then(|| format!("{:?}", outcome.status))),
325 usage: output.get("usage").cloned(),
326 status: status.clone(),
327 hooks: Vec::new(),
328 current: None,
329 };
330 let mut report = report;
333 if creates && succeeded {
334 let known: Vec<&str> = s.snapshot.features.iter().map(|f| f.dir.as_str()).collect();
335 let made = s
336 .format
337 .inspect(&root, None)
338 .map(|after| after.features)
339 .unwrap_or_default()
340 .into_iter()
341 .rfind(|f| !known.contains(&f.dir.as_str()));
342 if let Some(f) = made {
343 match s.format.set_current(&root, Some(&f.dir)) {
344 Ok(pointer) => {
345 report.current = Some(format!("{} (recorded in {pointer})", f.name));
346 }
347 Err(e) => report.current = Some(format!("{} (not recorded: {e})", f.name)),
348 }
349 }
350 } else if let Some(f) = s.snapshot.feature.as_ref() {
351 report.current = Some(format!(
352 "{} ({})",
353 f.feature.name,
354 crate::current::reason_text(s.snapshot.selection.reason, s.format)
355 ));
356 }
357 let tokens = |k: &str| {
358 report
359 .usage
360 .as_ref()
361 .and_then(|u| u[k].as_u64())
362 .unwrap_or(0)
363 };
364 let (code, message) = match status.as_str() {
365 "completed" => (
366 CommandStatus::Ok,
367 format!(
368 "{} done: {} file(s) changed in your checkout, uncommitted — review them (git diff, git status)",
369 skill.name,
370 report.changes.len()
371 ),
372 ),
373 "failed" => (
374 CommandStatus::RunFailed,
375 format!(
376 "{} failed: {}",
377 skill.name,
378 report.reason.clone().unwrap_or_default()
379 ),
380 ),
381 other => (
382 CommandStatus::NeedsHuman,
383 format!(
384 "{} stopped ({other}): {}",
385 skill.name,
386 report
387 .reason
388 .clone()
389 .filter(|r| !r.is_empty())
390 .or_else(|| {
391 (!report.questions.is_empty())
392 .then(|| format!("open questions: {}", report.questions.join(" | ")))
393 })
394 .or_else(|| report
395 .pending
396 .as_ref()
397 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
398 .unwrap_or_default()
399 ),
400 ),
401 };
402 live.event(&progress::Event::TaskFinished {
403 task: skill.name.clone(),
404 outcome: status,
405 message: redact(&message, secret),
406 run: None,
407 input_tokens: tokens("input_tokens"),
408 output_tokens: tokens("output_tokens"),
409 cache_read_tokens: tokens("cache_read_tokens"),
410 cache_write_tokens: tokens("cache_write_tokens"),
411 usd: None,
412 });
413 if code == CommandStatus::Ok
414 && let Some(e) = &event
415 {
416 match run_hooks_in(
417 &format!("after_{e}"),
418 arguments,
419 path,
420 feature,
421 state,
422 &provider,
423 secret,
424 options,
425 ) {
426 Ok(lines) => hook_lines.extend(lines),
427 Err(stop) => {
428 report.hooks = hook_lines;
429 return CommandResult::carried("skill", &stop)
430 .with_data(CommandData::Skill(Box::new(report)));
431 }
432 }
433 }
434 report.hooks = hook_lines;
435 CommandResult::new("skill", code, &redact(&message, secret))
436 .with_data(CommandData::Skill(Box::new(report)))
437}
438
439#[must_use]
441pub fn run(
442 path: &Path,
443 feature: Option<&str>,
444 name: &str,
445 arguments: &str,
446 options: &RunOptions,
447) -> CommandResult {
448 crate::with_driver("skill", path, None, |state, provider, key| {
449 run_in(
450 path,
451 feature,
452 name,
453 arguments,
454 state,
455 Arc::clone(provider),
456 Some(key),
457 options,
458 )
459 })
460}
461
462pub fn run_hooks(
466 event: &str,
467 arguments: &str,
468 path: &Path,
469 state: &Path,
470 provider: &Arc<dyn ModelProvider>,
471 secret: Option<&str>,
472 options: &RunOptions,
473) -> Result<Vec<String>, Box<CommandResult>> {
474 run_hooks_in(
475 event, arguments, path, None, state, provider, secret, options,
476 )
477}
478
479#[allow(clippy::too_many_arguments)]
481pub fn run_hooks_in(
482 event: &str,
483 arguments: &str,
484 path: &Path,
485 feature: Option<&str>,
486 state: &Path,
487 provider: &Arc<dyn ModelProvider>,
488 secret: Option<&str>,
489 options: &RunOptions,
490) -> Result<Vec<String>, Box<CommandResult>> {
491 let root = root_of(path)?;
492 let plan = crate::hooks::plan(&root, event)
493 .map_err(|e| Box::new(CommandResult::new("hooks", CommandStatus::ConfigError, &e)))?;
494 let mut lines: Vec<String> = Vec::new();
495 for hook in &plan.run {
496 let note = |text: String| {
497 if let Some(p) = &options.progress {
498 p.event(&progress::Event::Notice { text });
499 }
500 };
501 note(format!(
502 "{event} hook ({}): {}",
503 hook.extension, hook.command
504 ));
505 let res = run_in(
506 path,
507 feature,
508 &hook.skill(),
509 arguments,
510 state,
511 Arc::clone(provider),
512 secret,
513 options,
514 );
515 if res.status() != CommandStatus::Ok {
516 let status = if res.status() == CommandStatus::NotFound {
517 CommandStatus::ConfigError
518 } else {
519 res.status()
520 };
521 return Err(Box::new(CommandResult::new(
522 "hooks",
523 status,
524 &format!(
525 "mandatory {event} hook `{}` ({}) did not complete: {}",
526 hook.command,
527 hook.extension,
528 res.message()
529 ),
530 )));
531 }
532 lines.push(format!(
533 "ran {event} hook {} ({})",
534 hook.command, hook.extension
535 ));
536 }
537 for hook in &plan.offer {
538 let line = crate::hooks::offer_line(event, hook);
539 if let Some(p) = &options.progress {
540 p.event(&progress::Event::Notice { text: line.clone() });
541 }
542 lines.push(line);
543 }
544 for (hook, why) in &plan.skipped {
545 lines.push(format!(
546 "skipped {event} hook {} ({}): {why}",
547 hook.command, hook.extension
548 ));
549 }
550 Ok(lines)
551}
552
553fn core_event<'a>(planning: &Planning, skill: &'a str) -> Option<&'a str> {
555 let short = skill.strip_prefix(planning.prefix).unwrap_or(skill);
556 planning.core.contains(&short).then_some(short)
557}
558
559#[must_use]
563pub fn uncommitted(path: &Path, feature: Option<&str>) -> Vec<String> {
564 let format = crate::format::at(path);
565 let Some(snap) = format.open(path, feature) else {
566 return Vec::new();
567 };
568 let Some(f) = snap.feature.as_ref() else {
569 return Vec::new();
570 };
571 let (top, dir) = repo_dir(Path::new(&snap.root), &f.feature.dir);
572 crate::git::Git::at(&top)
573 .run(&[
574 "status",
575 "--porcelain",
576 "-z",
577 "--untracked-files=all",
578 "--",
579 &dir,
580 ])
581 .map(|out| {
582 String::from_utf8_lossy(&out)
583 .split('\0')
584 .filter(|e| e.len() > 3)
585 .map(|e| e[3..].to_owned())
586 .collect()
587 })
588 .unwrap_or_default()
589}
590
591#[must_use]
595pub fn commit_planning(path: &Path, feature: Option<&str>, paths: &[String]) -> CommandResult {
596 commit_planning_as(path, feature, paths, None)
597}
598
599#[must_use]
601pub fn commit_planning_as(
602 path: &Path,
603 feature: Option<&str>,
604 paths: &[String],
605 subject: Option<&str>,
606) -> CommandResult {
607 let refuse = |status, m: &str| CommandResult::new("commit", status, m);
608 let format = crate::format::at(path);
609 let Some(snap) = format.open(path, feature) else {
610 return refuse(
611 CommandStatus::NotAProject,
612 &(format.messages().not_a_project)(path),
613 );
614 };
615 let Some(f) = snap.feature.as_ref() else {
616 return refuse(
617 CommandStatus::SelectionNeeded,
618 "no current feature to commit for",
619 );
620 };
621 let (top, unit) = repo_dir(Path::new(&snap.root), &f.feature.dir);
622 let dir = format!("{unit}/");
623 if paths.is_empty() {
624 return refuse(CommandStatus::NotFound, "nothing to commit");
625 }
626 if let Some(p) = paths.iter().find(|p| !p.starts_with(&dir)) {
627 return refuse(
628 CommandStatus::UsageError,
629 &format!(
630 "`{p}` is not in {}; only planning files are committed here",
631 f.feature.dir
632 ),
633 );
634 }
635 let git = crate::git::Git::at(&top);
636 let names: Vec<&str> = paths
637 .iter()
638 .map(|p| p.rsplit('/').next().unwrap_or(p))
639 .collect();
640 let message = subject.map_or_else(
641 || format!("spec({}): {}", f.feature.name, names.join(", ")),
642 |t| format!("spec({}): {t}", f.feature.name),
643 );
644 let mut add = vec!["add", "--"];
645 add.extend(paths.iter().map(String::as_str));
646 let mut commit = vec!["commit", "-q", "-m", &message, "--"];
647 commit.extend(paths.iter().map(String::as_str));
648 match git.run(&add).and_then(|_| git.run(&commit)) {
649 Ok(_) => {
650 let id = git
651 .line(&["rev-parse", "--short", "HEAD"])
652 .unwrap_or_default();
653 CommandResult::new(
654 "commit",
655 CommandStatus::Ok,
656 &format!("committed {} file(s) as {id} \"{message}\"", paths.len()),
657 )
658 }
659 Err(e) => refuse(
660 CommandStatus::GitPrecondition,
661 &format!("commit failed: {}", e.0),
662 ),
663 }
664}
665
666fn repo_dir(root: &Path, unit_dir: &str) -> (std::path::PathBuf, String) {
669 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
670 let top = crate::git::Git::at(root)
671 .toplevel()
672 .ok()
673 .flatten()
674 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
675 let prefix = canonical(root)
676 .strip_prefix(&top)
677 .map(|p| p.to_string_lossy().replace('\\', "/"))
678 .unwrap_or_default();
679 let dir = if prefix.is_empty() {
680 unit_dir.to_owned()
681 } else {
682 format!("{prefix}/{unit_dir}")
683 };
684 (top, dir)
685}