1use anyhow::{bail, Context, Result};
2use std::process::{Command, Stdio};
3use walkdir::WalkDir;
4
5use crate::{
6 architecture, command_exists, generated_surfaces, mcp_registry, patterns, provider_manifest,
7 run_cargo, run_cmd, scripts_lane_b, scripts_lane_d, test_siblings, web_source,
8};
9
10pub(crate) fn contract_audit() -> Result<()> {
11 println!("==> contract-audit: local static/spec checks only");
12 println!("==> [1/13] cargo xtask check-architecture");
13 architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
14
15 println!("==> [2/13] cargo xtask patterns");
16 patterns::run(patterns::PatternOptions::default()).context("patterns contract check failed")?;
17
18 println!("==> [3/13] cargo xtask check-test-siblings");
19 test_siblings::check().context("test sibling check failed")?;
20
21 println!("==> [4/13] cargo xtask check-docs");
22 check_docs().context("generated docs check failed")?;
23
24 println!("==> [5/13] cargo xtask check-stale-claims");
25 check_stale_claims().context("stale claim check failed")?;
26
27 println!("==> [6/13] cargo xtask check-schema-docs --check");
28 scripts_lane_d::check_schema_docs(&["--check".to_owned()])
29 .context("schema docs check failed")?;
30
31 println!("==> [7/13] cargo xtask check-openapi --check");
32 scripts_lane_d::check_openapi(&["--check".to_owned()]).context("OpenAPI docs check failed")?;
33
34 println!("==> [8/13] cargo xtask check-mcp-registry");
35 mcp_registry::check_default(std::path::Path::new("."))
36 .context("MCP registry manifest check failed")?;
37
38 println!("==> [9/13] cargo xtask check-provider-manifest-contract");
39 provider_manifest::check().context("provider manifest contract check failed")?;
40
41 println!("==> [10/13] cargo xtask check-palette-manifest --check");
42 generated_surfaces::check_palette_manifest(&["--check".to_owned()])
43 .context("Palette manifest check failed")?;
44
45 println!("==> [11/13] cargo xtask generate-provider-surfaces --check");
46 generated_surfaces::provider_surfaces(&["--check".to_owned()])
47 .context("provider surfaces check failed")?;
48
49 println!("==> [12/13] cargo xtask check-scaffold-intent-contract");
50 scripts_lane_d::check_scaffold_intent_contract()
51 .context("scaffold intent contract check failed")?;
52
53 println!("==> [13/13] cargo xtask test-soma-features");
54 scripts_lane_b::test_soma_features(std::path::Path::new("."))
55 .context("Soma feature smoke failed")?;
56
57 println!("==> contract-audit: passed; no live upstream services were contacted");
58 Ok(())
59}
60
61pub(crate) fn generate_docs() -> Result<()> {
62 run_cmd("python3", &["scripts/generate-docs.py", "--write"])
63 .context("generated docs update failed")
64}
65
66pub(crate) fn check_docs() -> Result<()> {
67 run_cmd("python3", &["scripts/generate-docs.py", "--check"])
68 .context("generated docs are stale; run `cargo xtask generate-docs`")
69}
70
71pub(crate) fn check_stale_claims() -> Result<()> {
72 run_cmd("python3", &["scripts/check-stale-claims.py"]).context("stale claim check failed")
73}
74
75pub(crate) fn dist() -> Result<()> {
76 const BINARY_NAME: &str = "soma";
77
78 println!("==> Building release binary: {BINARY_NAME}");
79 run_cargo(&["build", "--release", "--locked", "--bin", BINARY_NAME])?;
80
81 let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
82 let artifact = std::path::Path::new(&target_dir)
83 .join("release")
84 .join(BINARY_NAME);
85 if !artifact.exists() {
86 bail!("Release binary not found at {artifact:?} — build must have failed");
87 }
88
89 println!("==> Built {artifact:?}");
90 println!("==> Run `just install-local` to install it to ~/.local/bin for plugin use");
91 Ok(())
92}
93
94pub(crate) fn doc(args: &[String]) -> Result<()> {
113 let mut cargo_args = vec![
114 "doc".to_owned(),
115 "--workspace".to_owned(),
116 "--no-deps".to_owned(),
117 "--locked".to_owned(),
118 ];
119 let mut all_features = true;
120 let mut open = false;
121 let mut strict = false;
122 let mut docsrs_cfg = false;
123 let mut packages: Vec<String> = Vec::new();
124 let mut document_private_items = false;
125
126 let mut iter = args.iter();
127 while let Some(arg) = iter.next() {
128 match arg.as_str() {
129 "--open" => open = true,
130 "--strict" => strict = true,
131 "--docsrs-cfg" => docsrs_cfg = true,
132 "--no-all-features" => all_features = false,
133 "--document-private-items" => document_private_items = true,
134 "--all-features" => all_features = true,
135 "-p" | "--package" => {
136 let pkg = iter
137 .next()
138 .ok_or_else(|| anyhow::anyhow!("`{arg}` requires a package name"))?;
139 packages.push(pkg.clone());
140 }
141 "--help" | "-h" => {
142 println!("{DOC_HELP}");
143 return Ok(());
144 }
145 other => bail!("Unknown `cargo xtask doc` option: {other:?}"),
146 }
147 }
148
149 if all_features {
150 cargo_args.push("--all-features".to_owned());
151 }
152 if document_private_items {
153 cargo_args.push("--document-private-items".to_owned());
154 }
155 for pkg in &packages {
156 cargo_args.push("-p".to_owned());
157 cargo_args.push(pkg.clone());
158 }
159 if open {
160 cargo_args.push("--open".to_owned());
161 }
162
163 print_doc_plan(
164 all_features,
165 &packages,
166 document_private_items,
167 open,
168 strict,
169 docsrs_cfg,
170 );
171
172 let mut cmd = Command::new("cargo");
177 cmd.args(&cargo_args);
178 let mut rustdocflags: Vec<String> = std::env::var("RUSTDOCFLAGS")
179 .ok()
180 .filter(|value| !value.trim().is_empty())
181 .into_iter()
182 .collect();
183 if strict {
184 rustdocflags.push("-D warnings".to_owned());
185 }
186 if docsrs_cfg {
187 rustdocflags.push("--cfg docsrs".to_owned());
188 }
189 if !rustdocflags.is_empty() {
190 cmd.env("RUSTDOCFLAGS", rustdocflags.join(" "));
191 }
192 cmd.stdin(Stdio::null());
193
194 let status = cmd
195 .status()
196 .with_context(|| "Failed to spawn `cargo doc`")?;
197 if !status.success() {
198 bail!("`cargo doc` exited with status {status}");
199 }
200
201 let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
202 let doc_root = std::path::Path::new(&target_dir).join("doc");
203
204 crate::doc_site::emit(&doc_root)?;
207
208 println!();
209 println!("==> Rustdoc generated under {doc_root:?}");
210 if !open {
211 println!(
212 " Open {}/index.html (or pass --open)",
213 doc_root.display()
214 );
215 }
216 if !strict {
217 println!(" Note: warnings were not fatal. CI enforces them via `--strict`.");
218 }
219 Ok(())
220}
221
222fn run_doc_strict() -> Result<()> {
227 let status = Command::new("cargo")
228 .args([
229 "doc",
230 "--workspace",
231 "--no-deps",
232 "--all-features",
233 "--locked",
234 ])
235 .env("RUSTDOCFLAGS", "-D warnings")
236 .stdin(Stdio::null())
237 .status()
238 .context("Failed to spawn `cargo doc`")?;
239 if !status.success() {
240 bail!("`cargo doc` exited with status {status}");
241 }
242 Ok(())
243}
244
245fn print_doc_plan(
246 all_features: bool,
247 packages: &[String],
248 document_private_items: bool,
249 open: bool,
250 strict: bool,
251 docsrs_cfg: bool,
252) {
253 println!("==> cargo xtask doc");
254 println!(
255 " features: {}",
256 if all_features { "all" } else { "default" }
257 );
258 println!(
259 " packages: {}",
260 if packages.is_empty() {
261 "workspace".to_owned()
262 } else {
263 packages.join(", ")
264 }
265 );
266 println!(
267 " private items: {}",
268 if document_private_items {
269 "yes"
270 } else {
271 "no (public only)"
272 }
273 );
274 println!(" open in browser: {}", if open { "yes" } else { "no" });
275 println!(
276 " strict (-D warn): {}",
277 if strict { "yes" } else { "no" }
278 );
279 println!(
280 " docsrs cfg: {}",
281 if docsrs_cfg {
282 "yes (--cfg docsrs; needs nightly rustdoc)"
283 } else {
284 "no"
285 }
286 );
287}
288
289const DOC_HELP: &str = "cargo xtask doc — generate Rust API documentation (rustdoc)
290
291USAGE:
292 cargo xtask doc [OPTIONS]
293
294OPTIONS:
295 --open Open the generated docs in a browser
296 --strict Treat rustdoc warnings as errors
297 (RUSTDOCFLAGS=\"-D warnings\"; mirrors CI)
298 --docsrs-cfg Append `--cfg docsrs` to RUSTDOCFLAGS so
299 feature-requirement badges render
300 (doc_auto_cfg; requires a nightly toolchain)
301 --no-all-features Document default features only (faster)
302 --all-features Document all features (default)
303 --document-private-items Include private items (internal/team docs)
304 -p, --package <NAME> Document a single workspace package
305 (repeatable; --no-deps is always set)
306 -h, --help Show this help
307
308DEFAULTS:
309 Public items only, no dependency docs, all features. These match the
310 repo's documented cargo-doc posture and what `.github/workflows/docs.yml`
311 deploys to GitHub Pages. Every run also writes target/doc/index.html (a
312 landing page listing all workspace crates) plus openapi.html/openapi.json
313 (the REST contract rendered with Redoc).
314
315EXAMPLES:
316 cargo xtask doc # full workspace API docs
317 cargo xtask doc --open # ...and open in a browser
318 cargo xtask doc -p soma-application # one crate only
319 cargo xtask doc --strict # CI-grade (warnings are errors)
320 RUSTUP_TOOLCHAIN=nightly cargo xtask doc --docsrs-cfg # feature badges";
321
322pub(crate) fn ci() -> Result<()> {
323 println!("==> [1/15] cargo fmt --check");
324 run_cargo(&["fmt", "--all", "--", "--check"]).context("fmt failed — run `cargo fmt` to fix")?;
325
326 println!("==> [2/15] cargo xtask check-architecture");
327 architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
328
329 println!("==> [3/15] cargo clippy");
330 run_cargo(&["clippy", "--all-targets", "--", "-D", "warnings"]).context("clippy failed")?;
331
332 println!("==> [4/15] cargo doc --workspace --no-deps --all-features (-D warnings)");
333 run_doc_strict().context("rustdoc failed — run `cargo xtask doc --strict` to see details")?;
334
335 println!("==> [5/15] cargo nextest run --profile ci");
336 if command_exists("cargo-nextest") {
337 run_cargo(&["nextest", "run", "--profile", "ci"]).context("nextest failed")?;
338 } else {
339 eprintln!(" (nextest not installed — falling back to cargo test)");
340 run_cargo(&["test"]).context("cargo test failed")?;
341 }
342
343 println!("==> [6/15] taplo check");
344 if command_exists("taplo") {
345 run_cmd("taplo", &["check"]).context("taplo check failed — run `taplo format` to fix")?;
346 } else {
347 eprintln!(" (taplo not installed — skipping TOML format check)");
348 }
349
350 println!("==> [7/15] cargo xtask patterns");
351 patterns::run(patterns::PatternOptions::default())
352 .context("PATTERNS.md contract check failed")?;
353
354 println!("==> [8/15] cargo xtask check-test-siblings");
355 test_siblings::check().context("test sibling check failed")?;
356
357 println!("==> [9/15] cargo xtask check-docs");
358 check_docs().context("generated docs check failed")?;
359
360 println!("==> [10/15] cargo xtask check-stale-claims");
361 check_stale_claims().context("stale claim check failed")?;
362
363 println!("==> [11/15] cargo xtask check-mcp-registry");
364 mcp_registry::check_default(std::path::Path::new("."))
365 .context("MCP registry manifest check failed")?;
366
367 println!("==> [12/15] cargo xtask check-provider-manifest-contract");
368 provider_manifest::check().context("provider manifest contract check failed")?;
369
370 println!("==> [13/15] cargo xtask check-palette-manifest --check");
371 generated_surfaces::check_palette_manifest(&["--check".to_owned()])
372 .context("Palette manifest check failed")?;
373
374 println!("==> [14/15] cargo xtask check-web-source-sync");
375 web_source::check().context("web source bundle drifted from apps/web")?;
376
377 println!("==> [15/15] cargo audit");
378 if command_exists("cargo-audit") {
379 run_cargo(&["audit"]).context("cargo audit found vulnerabilities")?;
380 } else {
381 eprintln!(
382 " (cargo-audit not installed — skipping; install with `cargo install cargo-audit`)"
383 );
384 }
385
386 println!("==> All CI checks passed!");
387 Ok(())
388}
389
390pub(crate) fn symlink_docs() -> Result<()> {
391 let mut created = 0usize;
392 let mut skipped = 0usize;
393
394 for entry in WalkDir::new(".")
395 .into_iter()
396 .filter_entry(|entry| {
397 let name = entry.file_name().to_string_lossy();
398 !matches!(name.as_ref(), ".git" | "target")
399 })
400 .filter_map(|entry| entry.ok())
401 {
402 if entry.file_name() != "CLAUDE.md" {
403 continue;
404 }
405
406 let dir = entry.path().parent().expect("CLAUDE.md has a parent");
407 for link_name in ["AGENTS.md", "GEMINI.md"] {
408 let link_path = dir.join(link_name);
409 if link_path.exists() || link_path.symlink_metadata().is_ok() {
410 println!(" skip {}", link_path.display());
411 skipped += 1;
412 continue;
413 }
414
415 #[cfg(unix)]
416 std::os::unix::fs::symlink("CLAUDE.md", &link_path)
417 .with_context(|| format!("Failed to create symlink at {}", link_path.display()))?;
418 #[cfg(windows)]
419 std::os::windows::fs::symlink_file("CLAUDE.md", &link_path).with_context(|| {
420 format!(
421 "Failed to create symlink at {} (may need developer mode on Windows)",
422 link_path.display()
423 )
424 })?;
425
426 println!(" link {} → CLAUDE.md", link_path.display());
427 created += 1;
428 }
429 }
430
431 println!("==> symlink-docs: {created} created, {skipped} already present");
432 Ok(())
433}
434
435pub(crate) fn check_env() -> Result<()> {
436 let required_vars: &[(&str, &str)] = &[];
437 let optional_vars: &[(&str, &str)] = &[
438 (
439 "SOMA_MCP_TOKEN",
440 "Static bearer token for /mcp (required in production; omit only in loopback dev mode)",
441 ),
442 (
443 "SOMA_MCP_HOST",
444 "Bind host (default 127.0.0.1 — set to 0.0.0.0 only with auth or trusted gateway)",
445 ),
446 ("SOMA_MCP_PORT", "Bind port (default 40060)"),
447 (
448 "RUST_LOG",
449 "Log filter (e.g. info,rmcp=warn — default: info in server mode, warn in stdio/cli)",
450 ),
451 ];
452
453 print_env_report(required_vars, optional_vars)
454}
455
456fn print_env_report(required_vars: &[(&str, &str)], optional_vars: &[(&str, &str)]) -> Result<()> {
457 let mut missing = Vec::new();
458
459 println!("==> Checking required environment variables:");
460 for &(var, desc) in required_vars {
461 match std::env::var(var) {
462 Ok(v) if !v.is_empty() => println!(" OK {var}"),
463 _ => {
464 println!(" MISSING {var}");
465 println!(" {desc}");
466 missing.push(var);
467 }
468 }
469 }
470
471 println!("\n==> Optional variables (missing = feature degraded, not error):");
472 for &(var, desc) in optional_vars {
473 match std::env::var(var) {
474 Ok(v) if !v.is_empty() => println!(" set {var} = {v}"),
475 _ => println!(" unset {var} ({desc})"),
476 }
477 }
478
479 if !missing.is_empty() {
480 bail!(
481 "\nMissing required environment variables: {}\n\
482 Copy .env.example to .env and fill in the values.",
483 missing.join(", ")
484 );
485 }
486
487 println!("\n==> All required environment variables are set.");
488 Ok(())
489}
490
491pub(crate) fn print_help() {
492 eprintln!("{HELP_TEXT}");
493}
494
495const HELP_TEXT: &str = "cargo xtask — repo automation for soma
496
497USAGE:
498 cargo xtask <command>
499
500COMMANDS:
501 dist Build release binary into Cargo target dir
502 ci Run all CI checks: fmt, clippy, nextest, taplo, audit
503 symlink-docs Create AGENTS.md + GEMINI.md symlinks next to every CLAUDE.md
504 check-env Validate required environment variables are set
505 check-architecture Validate workspace dependency-layer boundaries
506 check-test-siblings Verify every src/*.rs has a sibling *_tests.rs
507 patterns Check static contracts from docs/PATTERNS.md (--strict, --json)
508 contract-audit Run local static/spec checks without live upstream calls
509 scaffold Plan/apply/verify a generated project from Soma
510 codex-schema Rebuild/bisect the vendored codex-app-server-client schema
511 (see `cargo xtask codex-schema --help`)
512 cargo-generate Smoke-test real cargo-generate output (--no-cargo-check)
513 cargo-generate-post Internal generated-project rewrite command
514 generate-docs Generate volatile docs and metadata from canonical specs
515 doc Generate Rust API docs (rustdoc) for workspace crates (--open, --strict)
516 check-docs Validate generated docs and metadata are current
517 check-mcp-registry Validate server.json against docs/contracts/mcp-server.schema.json
518 check-stale-claims Fail on stale hardcoded Soma claims
519 check-cargo-generate Validate cargo-generate output
520 sync-web-source Copy apps/web into crates/soma-web/assets/source
521 check-web-source-sync Validate bundled web source matches apps/web
522 update-aurora-web Refresh Aurora registry components, validate, then sync
523 build-web Build optional apps/web static export
524 web-watch Watch apps/web and rebuild on changes
525 generate-cli Generate dist/soma-cli through mcporter
526 repair Rebuild and restart local soma-mcp runtime
527 test-mcp-auth Smoke-test HTTP MCP bearer auth
528 test-trace-headers Bounded live smoke for SOMA_MCP_TRACE_HEADERS
529 asciicheck Check/fix explicit files for non-ASCII characters
530 check-blob-size Check changed git blobs against size budget
531 block-env-commits Prevent staged .env secrets from being committed
532 check-coupled-files Check common companion-file drift in a diff
533 check-dependency-updates
534 Report Cargo dependency updates
535 check-file-size Check staged source files against size budgets
536 check-openapi Generate/check docs/generated/openapi.json
537 check-plugin-hook-contract
538 Audit cross-repo plugin hook contracts
539 run-ascii-check Check or fix tracked source/config/docs ASCII hygiene
540 check-plugin-stdio-smoke
541 Smoke-test installed plugin stdio binary
542 check-runtime-current Check systemd/Docker runtime artifact freshness
543 check-schema-docs Generate/check docs/MCP_SCHEMA.md
544 check-scaffold-intent-contract
545 Validate scaffold intent schema/examples
546 apply-no-mcp-marketplace
547 Remove bundled MCP registrations for the no-MCP branch
548 check-no-mcp-drift Validate marketplace-no-MCP invariants and branch drift
549 check-ts-client Regenerate/verify the checked-in codex-app-server-client TS REST client
550 sync-cargo Copy Cargo.lock into plugin data directories
551 pre-release-check Run release-readiness gate
552 refresh-docs Refresh ignored reference docs
553 test-soma-features
554 Run Soma invariant smoke tests
555 validate-plugin-layout
556 Validate Claude/Codex/Gemini plugin package layout
557 check-version-sync Validate release manifest version-file parity
558 check-release-versions [--base REF] [--head REF] [--mode pr|main] [--json]
559 Validate changed release components have fresh versions/tags
560 release-plan Print changed release components and candidate tags
561 sync-release-please-version
562 Sync version files to .release-please-manifest.json
563 bump-version Bump a component: cargo xtask bump-version soma patch
564 bump-soma-version Bump Soma component: cargo xtask bump-soma-version patch
565 changed-paths Classify changed files into CI routing categories
566 help Show this help
567
568CUSTOMIZE:
569 Add commands by extending the match block in xtask/src/main.rs.
570 Keep dependencies minimal — xtask should compile in seconds.";
571
572#[cfg(test)]
573#[path = "workspace_commands_tests.rs"]
574mod tests;