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/__init__.py +5 -0
- steersync/__main__.py +6 -0
- steersync/cli.py +768 -0
- steersync/config.py +532 -0
- steersync/errors.py +52 -0
- steersync/gitops.py +550 -0
- steersync/hooks.py +146 -0
- steersync/lint.py +631 -0
- steersync/locking.py +69 -0
- steersync/logging_utils.py +60 -0
- steersync/mcp_server.py +171 -0
- steersync/paths.py +98 -0
- steersync/resources/default_config.yaml +154 -0
- steersync/steering.py +457 -0
- steersync/sync.py +726 -0
- steersync-0.2.0.dist-info/METADATA +342 -0
- steersync-0.2.0.dist-info/RECORD +20 -0
- steersync-0.2.0.dist-info/WHEEL +4 -0
- steersync-0.2.0.dist-info/entry_points.txt +2 -0
- steersync-0.2.0.dist-info/licenses/LICENSE +190 -0
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()
|