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.
@@ -0,0 +1,60 @@
1
+ """Logging configuration with secret redaction and optional JSON output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sys
9
+ from datetime import UTC, datetime
10
+ from typing import Literal
11
+
12
+ _REDACT_PATTERNS: tuple[re.Pattern[str], ...] = (
13
+ re.compile(r"(?<=://)[^/@\s]+@"),
14
+ re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"),
15
+ re.compile(r"glpat-[A-Za-z0-9_-]{20,}"),
16
+ re.compile(r"(?<![A-Z0-9])(AKIA|ASIA)[0-9A-Z]{16}(?![A-Z0-9])"),
17
+ )
18
+
19
+
20
+ def redact(text: str) -> str:
21
+ """Mask credential like substrings before they reach any log sink."""
22
+ for pattern in _REDACT_PATTERNS:
23
+ text = pattern.sub("***", text)
24
+ return text
25
+
26
+
27
+ class _JsonFormatter(logging.Formatter):
28
+ def format(self, record: logging.LogRecord) -> str:
29
+ payload = {
30
+ "time": datetime.fromtimestamp(record.created, tz=UTC).isoformat(
31
+ timespec="milliseconds"
32
+ ),
33
+ "level": record.levelname,
34
+ "logger": record.name,
35
+ "message": redact(record.getMessage()),
36
+ }
37
+ if record.exc_info:
38
+ payload["exception"] = redact(self.formatException(record.exc_info))
39
+ return json.dumps(payload, ensure_ascii=False)
40
+
41
+
42
+ class _TextFormatter(logging.Formatter):
43
+ def format(self, record: logging.LogRecord) -> str:
44
+ return redact(super().format(record))
45
+
46
+
47
+ def configure_logging(
48
+ level: str = "INFO", fmt: Literal["text", "json"] = "text", *, quiet: bool = False
49
+ ) -> None:
50
+ """Configure the root logger for CLI use. Logs go to stderr so stdout stays machine readable."""
51
+ root = logging.getLogger()
52
+ for handler in list(root.handlers):
53
+ root.removeHandler(handler)
54
+ handler = logging.StreamHandler(sys.stderr)
55
+ formatter: logging.Formatter = (
56
+ _JsonFormatter() if fmt == "json" else _TextFormatter("%(levelname)s %(name)s: %(message)s")
57
+ )
58
+ handler.setFormatter(formatter)
59
+ root.addHandler(handler)
60
+ root.setLevel("ERROR" if quiet else level)
@@ -0,0 +1,171 @@
1
+ """Optional MCP stdio server exposing steersync operations as tools.
2
+
3
+ Requires the ``mcp`` extra: ``pip install "steersync[mcp]"``. All tools call the
4
+ same functions the CLI uses, so behaviour and safety checks are identical. Every
5
+ tool takes an optional ``collections`` list (steering, specs, skills, agents,
6
+ hooks, settings or any configured name); the default is every enabled collection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from steersync import gitops, sync
16
+ from steersync.config import Config, load_config
17
+ from steersync.errors import SteersyncError
18
+ from steersync.lint import lint_paths, merge_reports
19
+ from steersync.paths import workspace_collection_dir
20
+ from steersync.steering import FileSpec
21
+
22
+
23
+ def _load(cwd: str | None) -> tuple[Config, Path | None]:
24
+ start = Path(cwd).expanduser() if cwd else Path.cwd()
25
+ bootstrap = load_config(workspace_root=None)
26
+ root = gitops.repo_toplevel(bootstrap, start, env=gitops.git_environment(os.environ))
27
+ return load_config(workspace_root=root), root
28
+
29
+
30
+ def _plan_dict(plan: Any) -> dict[str, Any]: # noqa: ANN401
31
+ if plan is None:
32
+ return {}
33
+ return {
34
+ "add": list(plan.add),
35
+ "update": list(plan.update),
36
+ "delete": list(plan.delete),
37
+ "conflict": list(plan.conflict),
38
+ "unchanged": len(plan.unchanged),
39
+ "local_only": list(plan.local_only),
40
+ }
41
+
42
+
43
+ def _plans_dict(plans: dict[str, Any] | None) -> dict[str, Any]:
44
+ if plans is None:
45
+ return {}
46
+ return {name: _plan_dict(plan) for name, plan in plans.items()}
47
+
48
+
49
+ def pull_steering(
50
+ cwd: str | None = None,
51
+ force: bool = False,
52
+ dry_run: bool = False,
53
+ collections: list[str] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Pull global and workspace collections (steering, specs, skills, ...) from central."""
56
+ try:
57
+ config, root = _load(cwd)
58
+ result = sync.pull(
59
+ config, workspace_root=root, force=force, dry_run=dry_run, collections=collections
60
+ )
61
+ except (SteersyncError, OSError) as exc:
62
+ return {"ok": False, "error": str(exc)}
63
+ scopes: dict[str, dict[str, Any]] = {}
64
+ for item in result.results:
65
+ scopes.setdefault(item.scope, {})[item.collection] = {
66
+ "target": str(item.target_dir),
67
+ "plan": _plan_dict(item.plan),
68
+ "written": list(item.written),
69
+ }
70
+ return {"ok": True, "commit": result.commit, "dry_run": result.dry_run, "scopes": scopes}
71
+
72
+
73
+ def push_steering(
74
+ cwd: str | None = None,
75
+ dry_run: bool = False,
76
+ message: str | None = None,
77
+ collections: list[str] | None = None,
78
+ ) -> dict[str, Any]:
79
+ """Propose the workspace collections as one branch on the central repository."""
80
+ try:
81
+ config, root = _load(cwd)
82
+ if root is None:
83
+ return {"ok": False, "error": "not inside a git repository"}
84
+ result = sync.push(
85
+ config, workspace_root=root, dry_run=dry_run, message=message, collections=collections
86
+ )
87
+ except (SteersyncError, OSError) as exc:
88
+ return {"ok": False, "error": str(exc)}
89
+ return {
90
+ "ok": True,
91
+ "status": result.status,
92
+ "project": result.project,
93
+ "collections": list(result.collections),
94
+ "branch": result.branch,
95
+ "commit": result.commit,
96
+ "message": result.message,
97
+ "plan": _plan_dict(result.plan),
98
+ }
99
+
100
+
101
+ def diff_steering(cwd: str | None = None, collections: list[str] | None = None) -> dict[str, Any]:
102
+ """Compare local collections with the central repository without changing anything."""
103
+ try:
104
+ config, root = _load(cwd)
105
+ result = sync.status(config, workspace_root=root, collections=collections)
106
+ except (SteersyncError, OSError) as exc:
107
+ return {"ok": False, "error": str(exc)}
108
+ return {
109
+ "ok": True,
110
+ "project": result.project,
111
+ "remote_commit": result.remote_commit,
112
+ "workspace": _plans_dict(result.workspace),
113
+ "global": _plans_dict(result.global_),
114
+ }
115
+
116
+
117
+ def lint_steering(cwd: str | None = None, collections: list[str] | None = None) -> dict[str, Any]:
118
+ """Lint the workspace collections with each collection's profile."""
119
+ try:
120
+ config, root = _load(cwd)
121
+ if root is None:
122
+ return {"ok": False, "error": "not inside a git repository"}
123
+ reports = []
124
+ for collection in config.select_collections(collections).values():
125
+ target = workspace_collection_dir(collection, root)
126
+ if target is None:
127
+ continue
128
+ reports.append(
129
+ lint_paths(
130
+ [target],
131
+ config.lint,
132
+ root,
133
+ profile=collection.lint,
134
+ spec=FileSpec(collection.include, collection.exclude, collection.max_depth),
135
+ max_file_bytes=collection.max_file_bytes,
136
+ )
137
+ )
138
+ report = merge_reports(reports)
139
+ except (SteersyncError, OSError) as exc:
140
+ return {"ok": False, "error": str(exc)}
141
+ return {
142
+ "ok": report.ok,
143
+ "checked": report.checked,
144
+ "findings": [f._asdict() for f in report.findings],
145
+ }
146
+
147
+
148
+ def build_server() -> Any: # noqa: ANN401
149
+ """Create the MCP server. Imported lazily so the core has no MCP dependency."""
150
+ try:
151
+ from mcp.server.mcpserver import MCPServer # noqa: PLC0415
152
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
153
+ msg = "the MCP server needs the optional dependency: pip install 'steersync[mcp]'"
154
+ raise SteersyncError(msg) from exc
155
+ server = MCPServer(
156
+ "steersync",
157
+ instructions=(
158
+ "Sync Kiro steering, specs, skills, agents, hooks and settings with the central "
159
+ "repository. Pass collections=[...] to limit a tool to some collections."
160
+ ),
161
+ )
162
+ server.tool(name="pull_steering")(pull_steering)
163
+ server.tool(name="push_steering")(push_steering)
164
+ server.tool(name="diff_steering")(diff_steering)
165
+ server.tool(name="lint_steering")(lint_steering)
166
+ return server
167
+
168
+
169
+ def serve() -> None:
170
+ """Run the stdio MCP server until the client disconnects."""
171
+ build_server().run(transport="stdio")
steersync/paths.py ADDED
@@ -0,0 +1,98 @@
1
+ """Path resolution for Kiro directories, collection targets, locks and the clone cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+
9
+ from steersync.config import CollectionConfig, Config
10
+ from steersync.errors import UnsafePathError
11
+
12
+ DEFAULT_WORKSPACE_STEERING = ".kiro/steering"
13
+ DEFAULT_GLOBAL_STEERING = "steering"
14
+
15
+
16
+ def kiro_home(env: Mapping[str, str]) -> Path:
17
+ """Return the Kiro home directory honouring ``KIRO_HOME`` like kiro-cli does."""
18
+ override = env.get("KIRO_HOME")
19
+ if override:
20
+ return Path(override).expanduser()
21
+ return Path(env.get("HOME", "~")).expanduser() / ".kiro"
22
+
23
+
24
+ def global_collection_dir(collection: CollectionConfig, env: Mapping[str, str]) -> Path | None:
25
+ """Return the global target directory of a collection, or ``None`` without a global scope."""
26
+ if collection.global_path is None:
27
+ return None
28
+ return kiro_home(env) / collection.global_path
29
+
30
+
31
+ def workspace_collection_dir(collection: CollectionConfig, workspace_root: Path) -> Path | None:
32
+ """Return the workspace target directory of a collection, or ``None`` without one."""
33
+ if collection.workspace_path is None:
34
+ return None
35
+ return workspace_root / collection.workspace_path
36
+
37
+
38
+ def central_collection_dir(base: Path, collection: CollectionConfig) -> Path:
39
+ """Return the collection directory under a central ``global/`` or project directory."""
40
+ return ensure_within(base, Path(collection.central_dir or ""))
41
+
42
+
43
+ def global_steering_dir(config: Config, env: Mapping[str, str]) -> Path:
44
+ """Return the global steering directory (the ``steering`` collection or its default)."""
45
+ steering = config.collections.get("steering")
46
+ relative = (
47
+ steering.global_path
48
+ if steering is not None and steering.global_path is not None
49
+ else DEFAULT_GLOBAL_STEERING
50
+ )
51
+ return kiro_home(env) / relative
52
+
53
+
54
+ def workspace_steering_dir(config: Config, workspace_root: Path) -> Path:
55
+ """Return the workspace steering directory (the ``steering`` collection or its default)."""
56
+ steering = config.collections.get("steering")
57
+ relative = (
58
+ steering.workspace_path
59
+ if steering is not None and steering.workspace_path is not None
60
+ else DEFAULT_WORKSPACE_STEERING
61
+ )
62
+ return workspace_root / relative
63
+
64
+
65
+ def global_lock_path(config: Config, env: Mapping[str, str]) -> Path:
66
+ return kiro_home(env) / config.paths.global_lock
67
+
68
+
69
+ def workspace_lock_path(config: Config, workspace_root: Path) -> Path:
70
+ return workspace_root / config.paths.workspace_lock
71
+
72
+
73
+ def cache_root(config: Config, env: Mapping[str, str]) -> Path:
74
+ raw = config.paths.cache_dir
75
+ if raw.startswith("~"):
76
+ home = env.get("HOME")
77
+ return Path(home) / raw[2:] if home else Path(raw).expanduser()
78
+ return Path(raw)
79
+
80
+
81
+ def cache_repo_dir(config: Config, env: Mapping[str, str], remote_url: str) -> Path:
82
+ """Return a stable cache directory for a remote URL."""
83
+ digest = hashlib.sha256(remote_url.encode("utf-8")).hexdigest()[:16]
84
+ return cache_root(config, env) / "repos" / digest
85
+
86
+
87
+ def ensure_within(base: Path, candidate: Path) -> Path:
88
+ """Resolve ``candidate`` and ensure it stays inside ``base``."""
89
+ base_resolved = base.resolve()
90
+ target = (
91
+ (base_resolved / candidate).resolve()
92
+ if not candidate.is_absolute()
93
+ else candidate.resolve()
94
+ )
95
+ if target != base_resolved and base_resolved not in target.parents:
96
+ msg = f"path {candidate} escapes {base}"
97
+ raise UnsafePathError(msg)
98
+ return target
@@ -0,0 +1,154 @@
1
+ # Default steersync configuration. User configuration files are deep merged over
2
+ # these values. Environment variables override individual settings; see README.
3
+ remote:
4
+ # URL of the central steering repository. SSH or HTTPS. Authentication is
5
+ # handled by the git binary (ssh-agent or credential helper), never by steersync.
6
+ url: ""
7
+ default_branch: main
8
+ # Directory in the central repository holding steering applied to every workspace.
9
+ global_dir: global
10
+ # Directory in the central repository holding per project steering, keyed by project slug.
11
+ projects_dir: projects
12
+ # Optional allow list of remote hosts. Empty means any host is accepted.
13
+ allowed_hosts: []
14
+ # Allow file:// and absolute local paths. Only for tests and offline evaluation.
15
+ allow_local: false
16
+ # Git push options sent with each proposal branch. Placeholders: {default_branch},
17
+ # {project}, {user}, {hash}. GitLab turns these into a merge request; other
18
+ # servers ignore them silently.
19
+ push_options:
20
+ - merge_request.create
21
+ - merge_request.target={default_branch}
22
+ - merge_request.remove_source_branch
23
+ - "merge_request.title=steering: update {project} from {user}"
24
+
25
+ git:
26
+ binary: git
27
+ # Per invocation timeout. Network operations (clone, fetch, ls-remote, push)
28
+ # are retried on transient failures with exponential backoff and jitter.
29
+ timeout_seconds: 120
30
+ clone_depth: 1
31
+ retries: 2
32
+ retry_backoff_seconds: 2.0
33
+ retry_max_backoff_seconds: 30.0
34
+
35
+ branch:
36
+ # Placeholders: {project}, {user}, {hash}
37
+ template: "steering/{project}/{user}-{hash}"
38
+ hash_length: 8
39
+
40
+ paths:
41
+ workspace_lock: .kiro/steersync.lock.json
42
+ # Resolved against KIRO_HOME when set, else ~/.kiro
43
+ global_lock: steersync.lock.json
44
+ cache_dir: ~/.cache/steersync
45
+
46
+ # Collections are the kinds of Kiro content that are synced. Each one maps a
47
+ # workspace directory (relative to the repository root) and, optionally, a global
48
+ # directory (relative to KIRO_HOME, default ~/.kiro) onto a directory in the
49
+ # central repository: global/<central_dir>/ and projects/<slug>/<central_dir>/.
50
+ #
51
+ # include and exclude are glob patterns relative to the collection directory
52
+ # (** spans directories). lint selects the validation profile: steering,
53
+ # markdown, skill, agent, hooks, json or generic. Set enabled: false to switch a
54
+ # collection off, or add a new key to sync something else. Hidden files and
55
+ # directories are never synced.
56
+ collections:
57
+ steering:
58
+ description: Kiro steering documents
59
+ workspace_path: .kiro/steering
60
+ global_path: steering
61
+ central_dir: steering
62
+ include: ["**/*.md"]
63
+ lint: steering
64
+ specs:
65
+ description: Spec driven development plans (requirements, design, tasks)
66
+ workspace_path: .kiro/specs
67
+ global_path: null
68
+ central_dir: specs
69
+ include: ["**/*.md"]
70
+ lint: markdown
71
+ skills:
72
+ description: Agent Skills (SKILL.md with scripts, references and assets)
73
+ workspace_path: .kiro/skills
74
+ global_path: skills
75
+ central_dir: skills
76
+ include: ["**/*"]
77
+ exclude: ["**/__pycache__/**", "**/node_modules/**"]
78
+ lint: skill
79
+ max_file_bytes: 1048576
80
+ agents:
81
+ description: Custom agent definitions and their prompt files
82
+ workspace_path: .kiro/agents
83
+ global_path: agents
84
+ central_dir: agents
85
+ include: ["**/*.json", "**/*.md"]
86
+ lint: agent
87
+ hooks:
88
+ description: Standalone Kiro hooks (V3 and IDE 1.0)
89
+ workspace_path: .kiro/hooks
90
+ global_path: hooks
91
+ central_dir: hooks
92
+ include: ["**/*.json"]
93
+ lint: hooks
94
+ settings:
95
+ description: Workspace settings such as the language server configuration
96
+ workspace_path: .kiro/settings
97
+ global_path: null
98
+ central_dir: settings
99
+ include: ["lsp.json", "mcp.json"]
100
+ lint: json
101
+
102
+ sync:
103
+ # Delete locally managed files that disappeared from the central repository.
104
+ prune: true
105
+ # Fail a pull when the remote content has lint errors.
106
+ strict_pull: false
107
+ # Wait this long for another steersync process using the same cache clone.
108
+ lock_timeout_seconds: 120
109
+ # Safety limits for content read from the central repository.
110
+ max_files: 1000
111
+ max_total_bytes: 10485760
112
+
113
+ lint:
114
+ max_file_bytes: 65536
115
+ require_h1: true
116
+ allowed_extra_keys:
117
+ - x-steersync
118
+ file_reference_check: true
119
+ pymarkdown:
120
+ enabled: true
121
+ severity: warning
122
+ disabled_rules:
123
+ - MD013 # line length is not meaningful for prose steering
124
+ - MD041 # first line heading is handled by require_h1 with front matter awareness
125
+ forbidden_patterns:
126
+ - id: aws-access-key
127
+ pattern: "(?<![A-Z0-9])(AKIA|ASIA)[0-9A-Z]{16}(?![A-Z0-9])"
128
+ message: AWS access key identifier
129
+ - id: private-key
130
+ pattern: "-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY"
131
+ message: Private key material
132
+ - id: github-token
133
+ pattern: "gh[pousr]_[A-Za-z0-9]{36,}"
134
+ message: GitHub token
135
+ - id: gitlab-token
136
+ pattern: "glpat-[A-Za-z0-9_-]{20,}"
137
+ message: GitLab personal access token
138
+ - id: slack-token
139
+ pattern: "xox[abpr]-[0-9A-Za-z-]{10,}"
140
+ message: Slack token
141
+ - id: generic-secret
142
+ pattern: "(?i)(api[_-]?key|secret|password|token)\\s*[:=]\\s*['\"][A-Za-z0-9/+_=-]{16,}['\"]"
143
+ message: Possible hard coded credential
144
+
145
+ hook:
146
+ # When true the pre-push hook aborts the project push if steering sync fails.
147
+ strict: false
148
+ # Per git call timeout while running from a hook, so a slow server never
149
+ # stalls the developer's push for long.
150
+ timeout_seconds: 60
151
+
152
+ logging:
153
+ level: INFO
154
+ format: text