steersync 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
steersync/cli.py ADDED
@@ -0,0 +1,768 @@
1
+ """Command line interface for steersync, built with click."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ import click
14
+
15
+ from steersync import __version__, gitops, hooks, sync
16
+ from steersync.config import (
17
+ LINT_PROFILES,
18
+ Config,
19
+ LintProfile,
20
+ default_config_text,
21
+ load_config,
22
+ validate_remote_url,
23
+ )
24
+ from steersync.errors import SteersyncError
25
+ from steersync.lint import LintReport, lint_paths, merge_reports
26
+ from steersync.logging_utils import configure_logging
27
+ from steersync.paths import (
28
+ global_collection_dir,
29
+ workspace_collection_dir,
30
+ workspace_steering_dir,
31
+ )
32
+ from steersync.steering import ChangePlan, FileSpec
33
+
34
+ log = logging.getLogger("steersync")
35
+
36
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"], "max_content_width": 100}
37
+ ENV_FROM_HOOK_URL = "PRE_COMMIT_REMOTE_URL"
38
+
39
+
40
+ def _workspace_root(config: Config, env: Mapping[str, str]) -> Path | None:
41
+ return gitops.repo_toplevel(config, Path.cwd(), env=gitops.git_environment(env))
42
+
43
+
44
+ def _bootstrap(ctx: click.Context, *, propagate: bool = False) -> tuple[Config, Path | None]:
45
+ """Load configuration for the current directory and return it with the workspace root.
46
+
47
+ Configuration errors end the command with the error's exit code unless
48
+ ``propagate`` is set, in which case the caller handles them.
49
+ """
50
+ params = ctx.find_root().params
51
+ env = os.environ
52
+ try:
53
+ bootstrap = load_config(workspace_root=None, env=env, explicit=params.get("config_path"))
54
+ root = _workspace_root(bootstrap, env)
55
+ config = load_config(workspace_root=root, env=env, explicit=params.get("config_path"))
56
+ except SteersyncError as exc:
57
+ if propagate:
58
+ raise
59
+ _fail(exc)
60
+ raise AssertionError from exc # pragma: no cover - _fail always exits
61
+ configure_logging(
62
+ params.get("log_level") or config.logging.level,
63
+ params.get("log_format") or config.logging.format,
64
+ quiet=bool(params.get("quiet")),
65
+ )
66
+ return config, root
67
+
68
+
69
+ def _echo(message: str, *, quiet: bool = False, err: bool = False) -> None:
70
+ if not quiet:
71
+ click.echo(message, err=err)
72
+
73
+
74
+ def _plan_lines(label: str, plan: ChangePlan) -> list[str]:
75
+ lines = [f"{label}: {plan.change_count} change(s), {len(plan.unchanged)} unchanged"]
76
+ lines.extend(f" + {p}" for p in plan.add)
77
+ lines.extend(f" ~ {p}" for p in plan.update)
78
+ lines.extend(f" - {p}" for p in plan.delete)
79
+ lines.extend(f" ! {p} (locally modified, kept)" for p in plan.conflict)
80
+ lines.extend(f" ? {p} (local only)" for p in plan.local_only)
81
+ return lines
82
+
83
+
84
+ def _is_blank(plan: ChangePlan) -> bool:
85
+ """True when a collection has nothing on either side worth a line of output."""
86
+ return plan.is_empty and not (plan.unchanged or plan.conflict or plan.local_only)
87
+
88
+
89
+ def _plan_json(plan: ChangePlan | None) -> dict[str, Any] | None:
90
+ if plan is None:
91
+ return None
92
+ return {k: list(v) for k, v in plan._asdict().items()}
93
+
94
+
95
+ def _print_lint(report: LintReport, *, as_json: bool, quiet: bool) -> None:
96
+ if as_json:
97
+ click.echo(report.to_json())
98
+ return
99
+ for finding in report.findings:
100
+ _echo(finding.render(), quiet=quiet and finding.severity != "error", err=True)
101
+ _echo(
102
+ f"linted {report.checked} file(s): "
103
+ f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)",
104
+ quiet=quiet,
105
+ err=True,
106
+ )
107
+
108
+
109
+ def _fail(exc: SteersyncError) -> None:
110
+ log.error("%s", exc)
111
+ sys.exit(exc.exit_code)
112
+
113
+
114
+ def _plans_json(plans: Mapping[str, ChangePlan] | None) -> dict[str, Any] | None:
115
+ if plans is None:
116
+ return None
117
+ return {name: _plan_json(plan) for name, plan in plans.items()}
118
+
119
+
120
+ collection_option = click.option(
121
+ "-c",
122
+ "--collection",
123
+ "collections",
124
+ multiple=True,
125
+ metavar="NAME",
126
+ help="Limit to a collection (steering, specs, skills, ...). Repeatable; default is all.",
127
+ )
128
+
129
+
130
+ @click.group(context_settings=CONTEXT_SETTINGS)
131
+ @click.option(
132
+ "--config",
133
+ "config_path",
134
+ type=click.Path(path_type=Path, dir_okay=False),
135
+ help="Configuration file.",
136
+ )
137
+ @click.option(
138
+ "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False)
139
+ )
140
+ @click.option("--log-format", type=click.Choice(["text", "json"]))
141
+ @click.option("-q", "--quiet", is_flag=True, help="Only print errors.")
142
+ @click.version_option(__version__, prog_name="steersync")
143
+ def main(
144
+ config_path: Path | None, log_level: str | None, log_format: str | None, quiet: bool
145
+ ) -> None:
146
+ """Sync Kiro steering, specs, skills, agents, hooks and settings with a central repository."""
147
+
148
+
149
+ @main.command()
150
+ @click.option("--force", is_flag=True, help="Overwrite locally modified steering files.")
151
+ @click.option("--dry-run", is_flag=True, help="Show the plan without writing anything.")
152
+ @click.option("--workspace/--no-workspace", default=True, help="Include workspace steering.")
153
+ @click.option(
154
+ "--global/--no-global", "include_global", default=True, help="Include global steering."
155
+ )
156
+ @click.option("--strict", is_flag=True, help="Fail when remote steering has lint errors.")
157
+ @click.option("--remote-url", help="Project remote URL used to derive the project slug.")
158
+ @click.option(
159
+ "--best-effort",
160
+ is_flag=True,
161
+ help="Warn and exit 0 on failure (for Kiro session hooks and schedulers).",
162
+ )
163
+ @collection_option
164
+ @click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
165
+ @click.pass_context
166
+ def pull(
167
+ ctx: click.Context,
168
+ force: bool,
169
+ dry_run: bool,
170
+ workspace: bool,
171
+ include_global: bool,
172
+ strict: bool,
173
+ remote_url: str | None,
174
+ best_effort: bool,
175
+ collections: tuple[str, ...],
176
+ as_json: bool,
177
+ ) -> None:
178
+ """Fetch every collection (steering, specs, skills, ...) from the central repository."""
179
+ try:
180
+ config, root = _bootstrap(ctx, propagate=True)
181
+ except SteersyncError as exc:
182
+ _soft_failure(exc, soft=best_effort, label="steering pull skipped")
183
+ return
184
+ quiet = bool(ctx.find_root().params.get("quiet"))
185
+ try:
186
+ result = sync.pull(
187
+ config,
188
+ workspace_root=root,
189
+ include_workspace=workspace,
190
+ include_global=include_global,
191
+ remote_url=remote_url,
192
+ force=force,
193
+ dry_run=dry_run,
194
+ strict=strict or None,
195
+ collections=collections or None,
196
+ )
197
+ except (SteersyncError, OSError) as exc:
198
+ _soft_failure(exc, soft=best_effort, label="steering pull skipped")
199
+ return
200
+ if as_json:
201
+ scopes: dict[str, dict[str, Any]] = {}
202
+ for item in result.results:
203
+ scopes.setdefault(item.scope, {})[item.collection] = {
204
+ "target": str(item.target_dir),
205
+ "plan": _plan_json(item.plan),
206
+ "written": list(item.written),
207
+ }
208
+ click.echo(
209
+ json.dumps(
210
+ {"commit": result.commit, "dry_run": result.dry_run, "scopes": scopes}, indent=2
211
+ )
212
+ )
213
+ return
214
+ for item in result.results:
215
+ if _is_blank(item.plan):
216
+ continue
217
+ label = f"{item.scope}/{item.collection} ({item.target_dir})"
218
+ for line in _plan_lines(label, item.plan):
219
+ _echo(line, quiet=quiet)
220
+ verb = "would apply" if dry_run else "applied"
221
+ _echo(f"{verb} {result.change_count} change(s) from {result.commit[:12]}", quiet=quiet)
222
+ if result.conflicts:
223
+ _echo(
224
+ f"{len(result.conflicts)} locally modified file(s) kept; "
225
+ "rerun with --force to overwrite",
226
+ err=True,
227
+ )
228
+
229
+
230
+ @main.command()
231
+ @click.option(
232
+ "--from-hook",
233
+ is_flag=True,
234
+ help="Run from a git pre-push hook: never block the push unless strict.",
235
+ )
236
+ @click.option("--strict", is_flag=True, help="Exit non zero on failure even from a hook.")
237
+ @click.option("--remote-url", help="Project remote URL (from the hook arguments).")
238
+ @click.option(
239
+ "--scope", type=click.Choice(["workspace", "global"]), default="workspace", show_default=True
240
+ )
241
+ @click.option("--dry-run", is_flag=True, help="Compute the proposal without pushing.")
242
+ @click.option("--no-lint", is_flag=True, help="Skip lint checks before pushing.")
243
+ @click.option("-m", "--message", help="Commit message for the proposal.")
244
+ @collection_option
245
+ @click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
246
+ @click.pass_context
247
+ def push(
248
+ ctx: click.Context,
249
+ from_hook: bool,
250
+ strict: bool,
251
+ remote_url: str | None,
252
+ scope: str,
253
+ dry_run: bool,
254
+ no_lint: bool,
255
+ message: str | None,
256
+ collections: tuple[str, ...],
257
+ as_json: bool,
258
+ ) -> None:
259
+ """Propose local collections as one branch (and merge request) on the central repository."""
260
+ env = os.environ
261
+ if from_hook and env.get("STEERSYNC_ACTIVE") == "1":
262
+ return
263
+ quiet = bool(ctx.find_root().params.get("quiet")) or from_hook
264
+ try:
265
+ config, root = _bootstrap(ctx, propagate=True)
266
+ except SteersyncError as exc:
267
+ _soft_failure(exc, soft=from_hook and not strict, label="steering sync skipped")
268
+ return
269
+ strict = strict or config.hook.strict
270
+ if from_hook:
271
+ config = _hook_budget(config)
272
+ remote_url = remote_url or env.get(ENV_FROM_HOOK_URL) or None
273
+ refs = gitops.parse_pre_push_stdin(_read_stdin())
274
+ if refs and all(ref.is_delete for ref in refs):
275
+ return
276
+ if root is None:
277
+ _soft_failure(
278
+ SteersyncError("not inside a git repository"),
279
+ soft=from_hook and not strict,
280
+ label="steering sync skipped",
281
+ )
282
+ return
283
+ try:
284
+ result = sync.push(
285
+ config,
286
+ workspace_root=root,
287
+ env=env,
288
+ remote_url=remote_url,
289
+ scope="global" if scope == "global" else "workspace",
290
+ dry_run=dry_run,
291
+ run_lint=not no_lint,
292
+ message=message,
293
+ collections=collections or None,
294
+ )
295
+ except (SteersyncError, OSError) as exc:
296
+ _soft_failure(exc, soft=from_hook and not strict, label="steering sync skipped")
297
+ return
298
+ except Exception as exc:
299
+ # The pre-push contract: an unexpected bug must never block the user's push.
300
+ if not from_hook or strict:
301
+ raise
302
+ log.debug("unexpected error in hook mode", exc_info=True)
303
+ _soft_failure(exc, soft=True, label="steering sync skipped (unexpected error)")
304
+ return
305
+ if as_json:
306
+ click.echo(
307
+ json.dumps(
308
+ {**result._asdict(), "plan": _plan_json(result.plan), "lint": None}, indent=2
309
+ )
310
+ )
311
+ return
312
+ if result.lint is not None and result.lint.findings:
313
+ _print_lint(result.lint, as_json=False, quiet=quiet)
314
+ if result.plan is not None and not result.plan.is_empty:
315
+ for line in _plan_lines(result.project, result.plan):
316
+ _echo(line, quiet=quiet)
317
+ _echo(
318
+ f"{result.status}: {result.message}",
319
+ quiet=quiet and result.status in {"up-to-date", "nothing-to-push"},
320
+ )
321
+
322
+
323
+ def _soft_failure(exc: Exception, *, soft: bool, label: str) -> None:
324
+ """Report ``exc`` and exit non zero, or only warn when ``soft`` is set."""
325
+ if soft:
326
+ click.echo(f"steersync: {label}: {exc}", err=True)
327
+ return
328
+ if isinstance(exc, SteersyncError):
329
+ _fail(exc)
330
+ log.error("%s", exc)
331
+ sys.exit(1)
332
+
333
+
334
+ def _hook_budget(config: Config) -> Config:
335
+ """Bound git time while running from a hook so a slow server cannot stall a push."""
336
+ git = config.git.model_copy(
337
+ update={
338
+ "timeout_seconds": min(config.git.timeout_seconds, config.hook.timeout_seconds),
339
+ "retries": min(config.git.retries, 1),
340
+ }
341
+ )
342
+ return config.model_copy(update={"git": git})
343
+
344
+
345
+ def _read_stdin() -> str:
346
+ if sys.stdin is None or sys.stdin.isatty():
347
+ return ""
348
+ try:
349
+ return sys.stdin.read()
350
+ except OSError, ValueError:
351
+ return ""
352
+
353
+
354
+ @main.command()
355
+ @click.option("--remote-url", help="Project remote URL used to derive the project slug.")
356
+ @click.option("--no-lint", is_flag=True, help="Skip lint checks.")
357
+ @collection_option
358
+ @click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
359
+ @click.pass_context
360
+ def status(
361
+ ctx: click.Context,
362
+ remote_url: str | None,
363
+ no_lint: bool,
364
+ collections: tuple[str, ...],
365
+ as_json: bool,
366
+ ) -> None:
367
+ """Show how local collections differ from the central repository."""
368
+ config, root = _bootstrap(ctx)
369
+ try:
370
+ result = sync.status(
371
+ config,
372
+ workspace_root=root,
373
+ remote_url=remote_url,
374
+ run_lint=not no_lint,
375
+ collections=collections or None,
376
+ )
377
+ except SteersyncError as exc:
378
+ _fail(exc)
379
+ return
380
+ if as_json:
381
+ click.echo(
382
+ json.dumps(
383
+ {
384
+ "project": result.project,
385
+ "remote": gitops.redact_url(result.remote),
386
+ "remote_commit": result.remote_commit,
387
+ "workspace": _plans_json(result.workspace),
388
+ "global": _plans_json(result.global_),
389
+ "lint": json.loads(result.lint.to_json()) if result.lint else None,
390
+ },
391
+ indent=2,
392
+ )
393
+ )
394
+ return
395
+ click.echo(f"central: {gitops.redact_url(result.remote)} @ {(result.remote_commit or '')[:12]}")
396
+ if result.workspace is not None:
397
+ for name, plan in result.workspace.items():
398
+ if not _is_blank(plan):
399
+ for line in _plan_lines(f"workspace/{name} -> {result.project}", plan):
400
+ click.echo(line)
401
+ else:
402
+ click.echo("workspace: not in a git repository with an origin remote")
403
+ for name, plan in result.global_.items():
404
+ if not _is_blank(plan):
405
+ for line in _plan_lines(f"global/{name} <- central", plan):
406
+ click.echo(line)
407
+ if result.lint is not None:
408
+ _print_lint(result.lint, as_json=False, quiet=False)
409
+
410
+
411
+ @main.command()
412
+ @click.argument("paths", nargs=-1, type=click.Path(exists=True, path_type=Path))
413
+ @collection_option
414
+ @click.option(
415
+ "--profile",
416
+ type=click.Choice(list(LINT_PROFILES)),
417
+ help="Lint profile for explicit PATHS (default: steering, or the collection's profile).",
418
+ )
419
+ @click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
420
+ @click.pass_context
421
+ def lint(
422
+ ctx: click.Context,
423
+ paths: tuple[Path, ...],
424
+ collections: tuple[str, ...],
425
+ profile: str | None,
426
+ as_json: bool,
427
+ ) -> None:
428
+ """Validate synced content. Defaults to every collection in this workspace."""
429
+ config, root = _bootstrap(ctx)
430
+ quiet = bool(ctx.find_root().params.get("quiet"))
431
+ try:
432
+ selected = config.select_collections(collections or None)
433
+ except SteersyncError as exc:
434
+ _fail(exc)
435
+ return
436
+ if paths:
437
+ report = _lint_explicit_paths(config, root, paths, collections, profile)
438
+ else:
439
+ if root is None:
440
+ _fail(SteersyncError("no paths given and not inside a git repository"))
441
+ return
442
+ reports: list[LintReport] = []
443
+ for collection in selected.values():
444
+ target = workspace_collection_dir(collection, root)
445
+ if target is None:
446
+ continue
447
+ reports.append(
448
+ lint_paths(
449
+ [target],
450
+ config.lint,
451
+ root,
452
+ profile=collection.lint,
453
+ spec=FileSpec(collection.include, collection.exclude, collection.max_depth),
454
+ max_file_bytes=collection.max_file_bytes,
455
+ )
456
+ )
457
+ report = merge_reports(reports)
458
+ _print_lint(report, as_json=as_json, quiet=quiet)
459
+ if not report.ok:
460
+ sys.exit(4)
461
+
462
+
463
+ def _lint_explicit_paths(
464
+ config: Config,
465
+ root: Path | None,
466
+ paths: tuple[Path, ...],
467
+ collections: tuple[str, ...],
468
+ profile: str | None,
469
+ ) -> LintReport:
470
+ """Lint explicit paths, grouping each by the collection whose directory contains it.
471
+
472
+ ``--collection`` forces one collection for every path, ``--profile`` forces one
473
+ profile; a path outside every collection falls back to the steering profile.
474
+ """
475
+ forced = config.collections[collections[0]] if collections else None
476
+ groups: dict[str | None, list[Path]] = {}
477
+ for path in paths:
478
+ name = collections[0] if forced else _collection_for_path(config, root, path)
479
+ groups.setdefault(name, []).append(path)
480
+ reports: list[LintReport] = []
481
+ for name, group in groups.items():
482
+ chosen = config.collections[name] if name else None
483
+ reports.append(
484
+ lint_paths(
485
+ group,
486
+ config.lint,
487
+ root,
488
+ profile=_profile(profile or (chosen.lint if chosen else "steering")),
489
+ spec=FileSpec(chosen.include, chosen.exclude, chosen.max_depth) if chosen else None,
490
+ max_file_bytes=chosen.max_file_bytes if chosen else None,
491
+ )
492
+ )
493
+ return merge_reports(reports)
494
+
495
+
496
+ def _collection_for_path(config: Config, root: Path | None, path: Path) -> str | None:
497
+ """Return the enabled collection whose workspace directory contains ``path``."""
498
+ if root is None:
499
+ return None
500
+ resolved = path.resolve()
501
+ best: tuple[int, str] | None = None
502
+ for name, collection in config.enabled_collections().items():
503
+ target = workspace_collection_dir(collection, root)
504
+ if target is None:
505
+ continue
506
+ base = target.resolve()
507
+ if resolved == base or base in resolved.parents:
508
+ depth = len(base.parts)
509
+ if best is None or depth > best[0]:
510
+ best = (depth, name)
511
+ return best[1] if best else None
512
+
513
+
514
+ def _profile(name: str) -> LintProfile:
515
+ if name not in LINT_PROFILES: # pragma: no cover - click validates the choice
516
+ raise SteersyncError(f"unknown lint profile {name!r}")
517
+ return cast(LintProfile, name)
518
+
519
+
520
+ @main.command()
521
+ @click.option("--force", is_flag=True, help="Replace an existing steersync hook or chained hook.")
522
+ @click.pass_context
523
+ def install_hook(ctx: click.Context, force: bool) -> None:
524
+ """Install a chaining pre-push hook in this repository (core.hooksPath is never changed)."""
525
+ config, root = _bootstrap(ctx)
526
+ if root is None:
527
+ _fail(SteersyncError("not inside a git repository"))
528
+ return
529
+ try:
530
+ result = hooks.install_pre_push_hook(config, root, force=force)
531
+ except FileExistsError as exc:
532
+ _fail(SteersyncError(str(exc)))
533
+ return
534
+ click.echo(f"{result.action}: {result.path}")
535
+ if result.chained is not None:
536
+ click.echo(f"existing hook preserved and chained as {result.chained}")
537
+
538
+
539
+ @main.command()
540
+ @click.option(
541
+ "--write-config", is_flag=True, help="Write .kiro/steersync.yaml from the packaged defaults."
542
+ )
543
+ @click.option(
544
+ "--exclude/--no-exclude",
545
+ default=True,
546
+ help="Add the synced collection directories to .git/info/exclude.",
547
+ )
548
+ @click.option("--hook/--no-hook", default=False, help="Also install the pre-push hook.")
549
+ @click.pass_context
550
+ def init(ctx: click.Context, write_config: bool, exclude: bool, hook: bool) -> None:
551
+ """Prepare this repository for steersync."""
552
+ config, root = _bootstrap(ctx)
553
+ if root is None:
554
+ _fail(SteersyncError("not inside a git repository"))
555
+ return
556
+ if write_config:
557
+ target = root / ".kiro" / "steersync.yaml"
558
+ if target.exists():
559
+ click.echo(f"exists: {target}")
560
+ else:
561
+ target.parent.mkdir(parents=True, exist_ok=True)
562
+ target.write_text(default_config_text(), encoding="utf-8")
563
+ click.echo(f"wrote: {target}")
564
+ if exclude:
565
+ patterns = [
566
+ f"/{c.workspace_path.strip('/')}/"
567
+ for c in config.enabled_collections().values()
568
+ if c.workspace_path is not None and c.git_exclude
569
+ ]
570
+ patterns.append(f"/{config.paths.workspace_lock.strip('/')}")
571
+ changed = False
572
+ for pattern in patterns:
573
+ changed |= gitops.write_info_exclude(config, root, pattern)
574
+ click.echo(
575
+ f"{'updated' if changed else 'unchanged'}: .git/info/exclude ({', '.join(patterns)})"
576
+ )
577
+ if hook:
578
+ result = hooks.install_pre_push_hook(config, root, force=False)
579
+ click.echo(f"{result.action}: {result.path}")
580
+ steering_dir = workspace_steering_dir(config, root)
581
+ steering_dir.mkdir(parents=True, exist_ok=True)
582
+ click.echo(f"ready: {steering_dir}")
583
+ click.echo(f"collections: {', '.join(config.enabled_collections()) or '(none)'}")
584
+
585
+
586
+ @main.command("kiro-hook")
587
+ @click.option("--engine", type=click.Choice(["v2", "v3"]), default="v3", show_default=True)
588
+ @click.option("--write", is_flag=True, help="Write the v3 hook to .kiro/hooks/steersync.json.")
589
+ @click.pass_context
590
+ def kiro_hook(ctx: click.Context, engine: str, write: bool) -> None:
591
+ """Print (or write) a Kiro hook that runs `steersync pull` when a session starts."""
592
+ rendered = hooks.render_kiro_hook("v2" if engine == "v2" else "v3")
593
+ if write:
594
+ _, root = _bootstrap(ctx)
595
+ if root is None:
596
+ _fail(SteersyncError("not inside a git repository"))
597
+ return
598
+ if engine != "v3":
599
+ _fail(
600
+ SteersyncError(
601
+ "only v3 standalone hooks can be written; "
602
+ "paste the v2 JSON into your agent file"
603
+ )
604
+ )
605
+ return
606
+ target = root / ".kiro" / "hooks" / "steersync.json"
607
+ target.parent.mkdir(parents=True, exist_ok=True)
608
+ target.write_text(rendered, encoding="utf-8")
609
+ click.echo(f"wrote: {target}")
610
+ return
611
+ click.echo(rendered, nl=False)
612
+
613
+
614
+ @main.command("mcp-config")
615
+ @click.option(
616
+ "--command", default="uvx", show_default=True, help="Executable used to launch the server."
617
+ )
618
+ @click.option("--remote", help="Central repository URL to embed instead of ${STEERSYNC_REMOTE}.")
619
+ def mcp_config(command: str, remote: str | None) -> None:
620
+ """Print an mcp.json fragment registering the steersync MCP server."""
621
+ click.echo(hooks.render_mcp_config(command=command, remote=remote), nl=False)
622
+
623
+
624
+ @main.command()
625
+ def mcp() -> None:
626
+ """Run the MCP stdio server (requires the `mcp` extra)."""
627
+ from steersync.mcp_server import serve # noqa: PLC0415 (optional dependency)
628
+
629
+ try:
630
+ serve()
631
+ except SteersyncError as exc:
632
+ _fail(exc)
633
+
634
+
635
+ @main.group()
636
+ def config() -> None:
637
+ """Inspect configuration."""
638
+
639
+
640
+ @config.command("show")
641
+ @click.pass_context
642
+ def config_show(ctx: click.Context) -> None:
643
+ """Print the effective configuration as JSON (remote credentials redacted)."""
644
+ cfg, _ = _bootstrap(ctx)
645
+ data = cfg.model_dump(mode="json")
646
+ data["remote"]["url"] = gitops.redact_url(data["remote"]["url"])
647
+ click.echo(json.dumps(data, indent=2))
648
+
649
+
650
+ @config.command("validate")
651
+ @click.pass_context
652
+ def config_validate(ctx: click.Context) -> None:
653
+ """Validate the configuration and the central remote URL."""
654
+ cfg, _ = _bootstrap(ctx)
655
+ try:
656
+ validate_remote_url(
657
+ cfg.remote.url,
658
+ allowed_hosts=cfg.remote.allowed_hosts,
659
+ allow_local=cfg.remote.allow_local,
660
+ )
661
+ except SteersyncError as exc:
662
+ _fail(exc)
663
+ return
664
+ click.echo(f"ok: {cfg.source_file or 'packaged defaults'}")
665
+
666
+
667
+ @config.command("default")
668
+ def config_default() -> None:
669
+ """Print the packaged default configuration."""
670
+ click.echo(default_config_text(), nl=False)
671
+
672
+
673
+ @config.command("collections")
674
+ @click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
675
+ @click.pass_context
676
+ def config_collections(ctx: click.Context, as_json: bool) -> None:
677
+ """List the configured collections and where each one lives."""
678
+ cfg, root = _bootstrap(ctx)
679
+ env = os.environ
680
+ rows: list[dict[str, Any]] = []
681
+ for name, collection in cfg.collections.items():
682
+ workspace = workspace_collection_dir(collection, root) if root is not None else None
683
+ rows.append(
684
+ {
685
+ "name": name,
686
+ "enabled": collection.enabled,
687
+ "description": collection.description,
688
+ "central_dir": collection.central_dir,
689
+ "workspace_path": collection.workspace_path,
690
+ "workspace_dir": str(workspace) if workspace else None,
691
+ "global_path": collection.global_path,
692
+ "global_dir": (
693
+ str(d) if (d := global_collection_dir(collection, env)) is not None else None
694
+ ),
695
+ "include": list(collection.include),
696
+ "exclude": list(collection.exclude),
697
+ "lint": collection.lint,
698
+ }
699
+ )
700
+ if as_json:
701
+ click.echo(json.dumps(rows, indent=2))
702
+ return
703
+ for name, collection in cfg.collections.items():
704
+ state = "" if collection.enabled else " (disabled)"
705
+ click.echo(f"{name}{state}: {collection.description}".rstrip(": "))
706
+ click.echo(f" central: {collection.central_dir}/")
707
+ click.echo(f" workspace: {collection.workspace_path or '(none)'}")
708
+ click.echo(f" global: {collection.global_path or '(none)'}")
709
+ click.echo(f" include: {', '.join(collection.include)}")
710
+ if collection.exclude:
711
+ click.echo(f" exclude: {', '.join(collection.exclude)}")
712
+ click.echo(f" lint: {collection.lint}")
713
+
714
+
715
+ @main.command()
716
+ @click.pass_context
717
+ def doctor(ctx: click.Context) -> None:
718
+ """Check git, configuration and directories."""
719
+ config, root = _bootstrap(ctx)
720
+ env = os.environ
721
+ try:
722
+ click.echo(f"git: {gitops.git_version(config)}")
723
+ except SteersyncError as exc:
724
+ _fail(exc)
725
+ return
726
+ click.echo(f"config: {config.source_file or 'packaged defaults'}")
727
+ click.echo(f"remote: {gitops.redact_url(config.remote.url) or '(unset)'}")
728
+ click.echo(f"collections: {', '.join(config.enabled_collections()) or '(none)'}")
729
+ for name, collection in config.enabled_collections().items():
730
+ global_dir = global_collection_dir(collection, env)
731
+ if global_dir is not None:
732
+ click.echo(f"global {name}: {global_dir}")
733
+ if root is not None:
734
+ click.echo(f"workspace: {root}")
735
+ for name, collection in config.enabled_collections().items():
736
+ workspace_dir = workspace_collection_dir(collection, root)
737
+ if workspace_dir is not None:
738
+ click.echo(f"workspace {name}: {workspace_dir}")
739
+ origin = gitops.remote_url(config, root)
740
+ click.echo(f"origin: {gitops.redact_url(origin) if origin else '(none)'}")
741
+ if origin:
742
+ click.echo(f"project slug: {gitops.project_slug(origin)}")
743
+ else:
744
+ click.echo("workspace: not inside a git repository")
745
+
746
+
747
+ def run() -> None:
748
+ """Console entry point: turn unexpected failures into a clean message and exit code."""
749
+ try:
750
+ main(standalone_mode=True)
751
+ except KeyboardInterrupt:
752
+ click.echo("steersync: interrupted", err=True)
753
+ sys.exit(130)
754
+ except OSError as exc:
755
+ click.echo(f"steersync: error: {exc}", err=True)
756
+ sys.exit(1)
757
+ except Exception as exc:
758
+ click.echo(
759
+ f"steersync: unexpected error: {exc.__class__.__name__}: {exc}\n"
760
+ "rerun with --log-level DEBUG and report it if the problem persists",
761
+ err=True,
762
+ )
763
+ log.debug("unexpected error", exc_info=True)
764
+ sys.exit(1)
765
+
766
+
767
+ if __name__ == "__main__": # pragma: no cover
768
+ run()