lx-tooling 0.1.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.
lx_tooling/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Labinetix workflow CLI."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,15 @@
1
+ """AGENTS.md presence and content checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from lx_tooling.status import StatusRecord
8
+
9
+
10
+ def check_agents_file(repo_root: Path) -> list[StatusRecord]:
11
+ """Check that AGENTS.md exists."""
12
+ agents_path = repo_root / "AGENTS.md"
13
+ if agents_path.is_file():
14
+ return [StatusRecord("OK", "workflow.agents", "AGENTS.md present")]
15
+ return [StatusRecord("WARN", "workflow.agents", "AGENTS.md missing")]
@@ -0,0 +1,15 @@
1
+ """Documentation presence checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from lx_tooling.status import StatusRecord
8
+
9
+
10
+ def check_readme_file(repo_root: Path) -> list[StatusRecord]:
11
+ """Check that README.md exists."""
12
+ readme_path = repo_root / "README.md"
13
+ if readme_path.is_file():
14
+ return [StatusRecord("OK", "workflow.readme", "README.md present")]
15
+ return [StatusRecord("WARN", "workflow.readme", "README.md missing")]
@@ -0,0 +1,74 @@
1
+ """Pre-PR workflow checks (pure logic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from lx_tooling.checks.agents import check_agents_file
8
+ from lx_tooling.checks.docs import check_readme_file
9
+ from lx_tooling.policy import is_valid_branch_name
10
+ from lx_tooling.repo import RepoMetadata, discover_local_check_command
11
+ from lx_tooling.status import StatusRecord
12
+
13
+
14
+ def check_branch_name(branch: str, *, default_branch: str = "main") -> StatusRecord:
15
+ """Validate the current branch name."""
16
+ valid, detail = is_valid_branch_name(branch, default_branch=default_branch)
17
+ if valid:
18
+ return StatusRecord("OK", "workflow.branch", detail)
19
+ if branch == default_branch:
20
+ return StatusRecord("ERROR", "workflow.branch", detail)
21
+ return StatusRecord("WARN", "workflow.branch", detail)
22
+
23
+
24
+ def check_local_check_documented(repo_root: Path, metadata: RepoMetadata) -> StatusRecord:
25
+ """Warn when metadata defines a local check but no runner is discoverable."""
26
+ command = discover_local_check_command(repo_root, metadata)
27
+ if metadata.local_check and command:
28
+ return StatusRecord("OK", "workflow.local_check", command)
29
+ if metadata.local_check:
30
+ return StatusRecord(
31
+ "WARN",
32
+ "workflow.local_check",
33
+ f"configured as {metadata.local_check!r} but not discoverable",
34
+ )
35
+ if command:
36
+ return StatusRecord("OK", "workflow.local_check", command)
37
+ return StatusRecord("WARN", "workflow.local_check", "not configured")
38
+
39
+
40
+ def check_changed_paths(changed_files: list[str]) -> list[StatusRecord]:
41
+ """Warn when source changed without obvious test updates."""
42
+ records: list[StatusRecord] = []
43
+ src_changed = any(path.startswith("src/") for path in changed_files)
44
+ tests_changed = any(path.startswith("tests/") for path in changed_files)
45
+ if src_changed and not tests_changed:
46
+ records.append(
47
+ StatusRecord(
48
+ "WARN",
49
+ "workflow.tests",
50
+ "src/ changed without tests/ changes",
51
+ )
52
+ )
53
+ elif changed_files:
54
+ records.append(StatusRecord("OK", "workflow.tests", "changed paths reviewed"))
55
+ else:
56
+ records.append(StatusRecord("SKIP", "workflow.tests", "no changed files detected"))
57
+ return records
58
+
59
+
60
+ def run_workflow_checks(
61
+ *,
62
+ repo_root: Path,
63
+ branch: str,
64
+ metadata: RepoMetadata,
65
+ changed_files: list[str],
66
+ ) -> list[StatusRecord]:
67
+ """Run conservative pre-PR workflow checks."""
68
+ records: list[StatusRecord] = []
69
+ records.append(check_branch_name(branch, default_branch=metadata.pr_base))
70
+ records.extend(check_agents_file(repo_root))
71
+ records.extend(check_readme_file(repo_root))
72
+ records.append(check_local_check_documented(repo_root, metadata))
73
+ records.extend(check_changed_paths(changed_files))
74
+ return records
lx_tooling/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """Typer CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+
10
+ from lx_tooling import __version__
11
+ from lx_tooling import git as git_adapter
12
+ from lx_tooling import github as github_adapter
13
+ from lx_tooling.checks.workflow import run_workflow_checks
14
+ from lx_tooling.policy import (
15
+ DEFAULT_READY_LABELS,
16
+ issue_body_has_scope,
17
+ issue_has_ready_label,
18
+ )
19
+ from lx_tooling.repo import inspect_repository, load_repo_metadata, report_to_dict
20
+ from lx_tooling.status import StatusRecord, exit_code_for_records, format_line, records_to_dicts
21
+
22
+ app = typer.Typer(
23
+ name="lx",
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ help="Labinetix workflow CLI for humans and AI agents.",
27
+ )
28
+
29
+ repo_app = typer.Typer(help="Inspect repository metadata and layout.")
30
+ workflow_app = typer.Typer(help="Run local workflow checks before opening a PR.")
31
+ issue_app = typer.Typer(help="Read GitHub issue information.")
32
+
33
+ app.add_typer(repo_app, name="repo")
34
+ app.add_typer(workflow_app, name="workflow")
35
+ app.add_typer(issue_app, name="issue")
36
+
37
+
38
+ def _version_callback(value: bool) -> None:
39
+ if value:
40
+ typer.echo(f"lx {__version__}")
41
+ raise typer.Exit()
42
+
43
+
44
+ def _print_records(records: list, *, as_json: bool) -> None:
45
+ if as_json:
46
+ typer.echo(json.dumps(records_to_dicts(records), indent=2))
47
+ return
48
+ for record in records:
49
+ typer.echo(format_line(record))
50
+
51
+
52
+ @app.callback()
53
+ def main(
54
+ version: Annotated[
55
+ bool | None,
56
+ typer.Option("--version", callback=_version_callback, is_eager=True),
57
+ ] = None,
58
+ ) -> None:
59
+ """Labinetix workflow CLI."""
60
+
61
+
62
+ @repo_app.command("inspect")
63
+ def repo_inspect(
64
+ json_output: Annotated[
65
+ bool,
66
+ typer.Option("--json", help="Emit machine-readable JSON."),
67
+ ] = False,
68
+ ) -> None:
69
+ """Inspect the current repository and report workflow-relevant files."""
70
+ try:
71
+ report = inspect_repository()
72
+ except git_adapter.GitError as exc:
73
+ typer.echo(format_line(StatusRecord("ERROR", "repo.root", str(exc))), err=True)
74
+ raise typer.Exit(2) from None
75
+
76
+ if json_output:
77
+ typer.echo(json.dumps(report_to_dict(report), indent=2))
78
+ else:
79
+ for record in report.records:
80
+ typer.echo(format_line(record))
81
+
82
+ raise typer.Exit(exit_code_for_records(report.records))
83
+
84
+
85
+ @workflow_app.command("check")
86
+ def workflow_check(
87
+ json_output: Annotated[
88
+ bool,
89
+ typer.Option("--json", help="Emit machine-readable JSON."),
90
+ ] = False,
91
+ ) -> None:
92
+ """Run conservative local workflow checks before opening a PR."""
93
+ try:
94
+ root = git_adapter.repo_root()
95
+ branch = git_adapter.current_branch(root)
96
+ changed = git_adapter.changed_files(root)
97
+ except git_adapter.GitError as exc:
98
+ typer.echo(format_line(StatusRecord("ERROR", "workflow.git", str(exc))), err=True)
99
+ raise typer.Exit(2) from None
100
+
101
+ metadata = load_repo_metadata(root)
102
+ records = run_workflow_checks(
103
+ repo_root=root,
104
+ branch=branch,
105
+ metadata=metadata,
106
+ changed_files=changed,
107
+ )
108
+ _print_records(records, as_json=json_output)
109
+ raise typer.Exit(exit_code_for_records(records))
110
+
111
+
112
+ @issue_app.command("view")
113
+ def issue_view(
114
+ number: Annotated[int, typer.Argument(help="GitHub issue number.")],
115
+ json_output: Annotated[
116
+ bool,
117
+ typer.Option("--json", help="Emit machine-readable JSON."),
118
+ ] = False,
119
+ ) -> None:
120
+ """Read a GitHub issue and summarize Labinetix readiness hints."""
121
+ if not github_adapter.gh_available():
122
+ typer.echo(
123
+ format_line(
124
+ StatusRecord(
125
+ "ERROR",
126
+ "issue.gh",
127
+ "gh is not installed; install GitHub CLI and run gh auth login",
128
+ )
129
+ ),
130
+ err=True,
131
+ )
132
+ raise typer.Exit(2)
133
+
134
+ try:
135
+ root = git_adapter.repo_root()
136
+ issue = github_adapter.issue_view_json(number, root)
137
+ except git_adapter.GitError as exc:
138
+ typer.echo(format_line(StatusRecord("ERROR", "issue.git", str(exc))), err=True)
139
+ raise typer.Exit(2) from None
140
+ except github_adapter.GhError as exc:
141
+ typer.echo(format_line(StatusRecord("ERROR", "issue.gh", str(exc))), err=True)
142
+ raise typer.Exit(2) from None
143
+
144
+ metadata = load_repo_metadata(root)
145
+ ready_labels = metadata.ready_labels or DEFAULT_READY_LABELS
146
+ labels = [label["name"] for label in issue.get("labels", [])]
147
+ body = issue.get("body") or ""
148
+
149
+ records: list[StatusRecord] = [
150
+ StatusRecord("OK", "issue.number", str(number)),
151
+ StatusRecord("OK", "issue.state", issue.get("state", "unknown")),
152
+ StatusRecord("OK", "issue.title", issue.get("title", "")),
153
+ StatusRecord("OK", "issue.url", issue.get("url", "")),
154
+ ]
155
+
156
+ if issue_has_ready_label(labels, ready_labels=ready_labels):
157
+ records.append(StatusRecord("OK", "issue.ready", f"labels: {', '.join(labels)}"))
158
+ else:
159
+ expected = ", ".join(ready_labels)
160
+ records.append(
161
+ StatusRecord(
162
+ "WARN",
163
+ "issue.ready",
164
+ f"missing ready label; expected one of: {expected}",
165
+ )
166
+ )
167
+
168
+ if issue_body_has_scope(body):
169
+ records.append(StatusRecord("OK", "issue.scope", "scope section detected"))
170
+ else:
171
+ records.append(StatusRecord("WARN", "issue.scope", "no scope section detected in body"))
172
+
173
+ if json_output:
174
+ payload: dict[str, Any] = {
175
+ "issue": issue,
176
+ "records": records_to_dicts(records),
177
+ }
178
+ typer.echo(json.dumps(payload, indent=2))
179
+ else:
180
+ for record in records:
181
+ typer.echo(format_line(record))
182
+ typer.echo("")
183
+ typer.echo(issue.get("body", "").strip())
184
+
185
+ raise typer.Exit(exit_code_for_records(records))
lx_tooling/git.py ADDED
@@ -0,0 +1,80 @@
1
+ """Thin git subprocess adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ class GitError(Exception):
10
+ """Raised when a git command fails."""
11
+
12
+
13
+ def run_git(*args: str, cwd: Path | None = None) -> str:
14
+ """Run a git command and return stdout."""
15
+ result = subprocess.run(
16
+ ["git", *args],
17
+ cwd=cwd,
18
+ capture_output=True,
19
+ text=True,
20
+ check=False,
21
+ )
22
+ if result.returncode != 0:
23
+ message = result.stderr.strip() or result.stdout.strip() or "git command failed"
24
+ raise GitError(message)
25
+ return result.stdout.strip()
26
+
27
+
28
+ def repo_root(start: Path | None = None) -> Path:
29
+ """Return the git repository root for the current working tree."""
30
+ cwd = start or Path.cwd()
31
+ result = subprocess.run(
32
+ ["git", "rev-parse", "--show-toplevel"],
33
+ cwd=cwd,
34
+ capture_output=True,
35
+ text=True,
36
+ check=False,
37
+ )
38
+ if result.returncode != 0:
39
+ raise GitError("not a git repository")
40
+ return Path(result.stdout.strip())
41
+
42
+
43
+ def current_branch(repo_root_path: Path | None = None) -> str:
44
+ """Return the current branch name."""
45
+ root = repo_root_path or repo_root()
46
+ return run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=root)
47
+
48
+
49
+ def is_clean(repo_root_path: Path | None = None) -> bool:
50
+ """Return whether the working tree has no uncommitted changes."""
51
+ root = repo_root_path or repo_root()
52
+ return run_git("status", "--porcelain", cwd=root) == ""
53
+
54
+
55
+ def remote_repo_name(repo_root_path: Path | None = None) -> str | None:
56
+ """Return the repository name from origin, if configured."""
57
+ root = repo_root_path or repo_root()
58
+ try:
59
+ url = run_git("remote", "get-url", "origin", cwd=root)
60
+ except GitError:
61
+ return None
62
+
63
+ name = url.rstrip("/").removesuffix(".git")
64
+ if ":" in name and "@" in name:
65
+ return name.rsplit(":", maxsplit=1)[-1].split("/")[-1]
66
+ return name.rsplit("/", maxsplit=1)[-1]
67
+
68
+
69
+ def changed_files(repo_root_path: Path | None = None) -> list[str]:
70
+ """Return paths changed relative to the default branch merge base."""
71
+ root = repo_root_path or repo_root()
72
+ try:
73
+ base = run_git("merge-base", "HEAD", "main", cwd=root)
74
+ except GitError:
75
+ base = run_git("rev-parse", "HEAD", cwd=root)
76
+
77
+ diff = run_git("diff", "--name-only", f"{base}...HEAD", cwd=root)
78
+ if not diff:
79
+ return []
80
+ return diff.splitlines()
lx_tooling/github.py ADDED
@@ -0,0 +1,50 @@
1
+ """Thin GitHub CLI subprocess adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class GhError(Exception):
13
+ """Raised when a gh command fails."""
14
+
15
+
16
+ def gh_available() -> bool:
17
+ """Return whether the gh executable is on PATH."""
18
+ return shutil.which("gh") is not None
19
+
20
+
21
+ def run_gh(*args: str, cwd: Path | None = None) -> str:
22
+ """Run a gh command and return stdout."""
23
+ if not gh_available():
24
+ raise GhError("gh is not installed or not on PATH")
25
+
26
+ result = subprocess.run(
27
+ ["gh", *args],
28
+ cwd=cwd,
29
+ capture_output=True,
30
+ text=True,
31
+ check=False,
32
+ )
33
+ if result.returncode != 0:
34
+ message = result.stderr.strip() or result.stdout.strip() or "gh command failed"
35
+ raise GhError(message)
36
+ return result.stdout.strip()
37
+
38
+
39
+ def issue_view_json(number: int, repo_root_path: Path | None = None) -> dict[str, Any]:
40
+ """Fetch issue metadata as a parsed JSON object."""
41
+ cwd = repo_root_path or Path.cwd()
42
+ raw = run_gh(
43
+ "issue",
44
+ "view",
45
+ str(number),
46
+ "--json",
47
+ "title,body,state,labels,assignees,url",
48
+ cwd=cwd,
49
+ )
50
+ return json.loads(raw)
lx_tooling/policy.py ADDED
@@ -0,0 +1,60 @@
1
+ """Pure workflow policy helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ BRANCH_PREFIXES: tuple[str, ...] = (
6
+ "feat",
7
+ "fix",
8
+ "docs",
9
+ "test",
10
+ "ci",
11
+ "chore",
12
+ "adr",
13
+ )
14
+
15
+ DEFAULT_READY_LABELS: tuple[str, ...] = (
16
+ "status:ready-for-agent",
17
+ "agent:allowed",
18
+ )
19
+
20
+ SCOPE_MARKERS: tuple[str, ...] = (
21
+ "## Scope",
22
+ "## In scope",
23
+ "- In scope:",
24
+ )
25
+
26
+
27
+ def is_valid_branch_name(name: str, *, default_branch: str = "main") -> tuple[bool, str]:
28
+ """Return whether a branch name follows the Labinetix convention."""
29
+ if name == default_branch:
30
+ return False, f"branch is the default branch ({default_branch})"
31
+
32
+ if "/" not in name:
33
+ return False, "expected <prefix>/<topic>"
34
+
35
+ prefix, topic = name.split("/", 1)
36
+ if prefix not in BRANCH_PREFIXES:
37
+ allowed = ", ".join(BRANCH_PREFIXES)
38
+ return False, f"prefix must be one of: {allowed}"
39
+
40
+ if not topic:
41
+ return False, "topic segment must not be empty"
42
+
43
+ normalized = topic.replace("-", "").replace("_", "")
44
+ if not normalized.isalnum():
45
+ return False, "topic must use letters, numbers, hyphens, or underscores"
46
+
47
+ return True, "branch name follows convention"
48
+
49
+
50
+ def issue_has_ready_label(labels: list[str], *, ready_labels: tuple[str, ...]) -> bool:
51
+ """Return whether an issue has at least one configured ready label."""
52
+ label_set = set(labels)
53
+ return any(label in label_set for label in ready_labels)
54
+
55
+
56
+ def issue_body_has_scope(body: str) -> bool:
57
+ """Return whether the issue body appears to document scope."""
58
+ if not body.strip():
59
+ return False
60
+ return any(marker in body for marker in SCOPE_MARKERS)
lx_tooling/repo.py ADDED
@@ -0,0 +1,186 @@
1
+ """Repository metadata and inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from lx_tooling import git
11
+ from lx_tooling.status import StatusRecord
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RepoMetadata:
16
+ name: str | None = None
17
+ repo_type: str | None = None
18
+ release: bool | None = None
19
+ artifacts: bool | None = None
20
+ local_check: str | None = None
21
+ docs_check: str | None = None
22
+ pr_base: str = "main"
23
+ ready_labels: tuple[str, ...] = ()
24
+ release_labels: tuple[str, ...] = ()
25
+
26
+
27
+ @dataclass
28
+ class RepoInspectReport:
29
+ repo_root: Path
30
+ repository_name: str
31
+ repo_type: str
32
+ metadata: RepoMetadata
33
+ records: list[StatusRecord] = field(default_factory=list)
34
+ ci_workflows: list[str] = field(default_factory=list)
35
+ local_check_command: str | None = None
36
+
37
+
38
+ def load_repo_metadata(repo_root: Path) -> RepoMetadata:
39
+ """Load optional .labinetix/repo.toml metadata."""
40
+ metadata_path = repo_root / ".labinetix" / "repo.toml"
41
+ if not metadata_path.is_file():
42
+ return RepoMetadata()
43
+
44
+ with metadata_path.open("rb") as handle:
45
+ data = tomllib.load(handle)
46
+
47
+ repo_section = data.get("repo", {})
48
+ workflow_section = data.get("workflow", {})
49
+ github_section = data.get("github", {})
50
+
51
+ return RepoMetadata(
52
+ name=repo_section.get("name"),
53
+ repo_type=repo_section.get("type"),
54
+ release=repo_section.get("release"),
55
+ artifacts=repo_section.get("artifacts"),
56
+ local_check=workflow_section.get("local_check"),
57
+ docs_check=workflow_section.get("docs_check"),
58
+ pr_base=workflow_section.get("pr_base", "main"),
59
+ ready_labels=tuple(github_section.get("ready_labels", [])),
60
+ release_labels=tuple(github_section.get("release_labels", [])),
61
+ )
62
+
63
+
64
+ def detect_repo_type(repo_root: Path, metadata: RepoMetadata) -> str:
65
+ """Infer repository type from metadata or marker files."""
66
+ if metadata.repo_type:
67
+ return metadata.repo_type
68
+ if (repo_root / "Cargo.toml").is_file():
69
+ return "rust"
70
+ if (repo_root / "pyproject.toml").is_file():
71
+ return "python"
72
+ if (repo_root / "package.json").is_file():
73
+ return "node"
74
+ return "unknown"
75
+
76
+
77
+ def discover_local_check_command(repo_root: Path, metadata: RepoMetadata) -> str | None:
78
+ """Return the best-known local check command for the repository."""
79
+ if metadata.local_check:
80
+ return metadata.local_check
81
+ if (repo_root / "justfile").is_file() or (repo_root / "Justfile").is_file():
82
+ return "just check"
83
+ return None
84
+
85
+
86
+ def inspect_repository(start: Path | None = None) -> RepoInspectReport:
87
+ """Inspect repository layout and policy-relevant files."""
88
+ root = git.repo_root(start)
89
+ metadata = load_repo_metadata(root)
90
+ remote_name = git.remote_repo_name(root)
91
+ repository_name = metadata.name or remote_name or root.name
92
+ repo_type = detect_repo_type(root, metadata)
93
+ local_check = discover_local_check_command(root, metadata)
94
+
95
+ records: list[StatusRecord] = []
96
+ records.append(StatusRecord("OK", "repo.root", str(root)))
97
+ records.append(StatusRecord("OK", "repo.name", repository_name))
98
+ records.append(StatusRecord("OK", "repo.type", repo_type))
99
+
100
+ for subject, path in (
101
+ ("repo.readme", root / "README.md"),
102
+ ("repo.agents", root / "AGENTS.md"),
103
+ ("repo.metadata", root / ".labinetix" / "repo.toml"),
104
+ ):
105
+ if path.is_file():
106
+ records.append(StatusRecord("OK", subject, str(path.relative_to(root))))
107
+ else:
108
+ level = "WARN" if subject != "repo.metadata" else "SKIP"
109
+ records.append(StatusRecord(level, subject, "missing"))
110
+
111
+ docs_dir = root / "docs"
112
+ if docs_dir.is_dir():
113
+ records.append(StatusRecord("OK", "repo.docs", "docs/"))
114
+ else:
115
+ records.append(StatusRecord("WARN", "repo.docs", "missing docs/"))
116
+
117
+ adr_dir = root / "docs" / "decisions"
118
+ design_dir = root / "docs" / "design"
119
+ if adr_dir.is_dir():
120
+ records.append(StatusRecord("OK", "repo.adr", "docs/decisions/"))
121
+ elif design_dir.is_dir():
122
+ records.append(StatusRecord("OK", "repo.adr", "docs/design/"))
123
+ else:
124
+ records.append(StatusRecord("WARN", "repo.adr", "missing docs/decisions/ or docs/design/"))
125
+
126
+ workflow_dir = root / ".github" / "workflows"
127
+ ci_workflows: list[str] = []
128
+ if workflow_dir.is_dir():
129
+ for workflow_file in sorted(workflow_dir.glob("*.yml")) + sorted(
130
+ workflow_dir.glob("*.yaml")
131
+ ):
132
+ ci_workflows.append(workflow_file.name)
133
+ if ci_workflows:
134
+ records.append(StatusRecord("OK", "repo.ci", ", ".join(ci_workflows)))
135
+ else:
136
+ records.append(StatusRecord("WARN", "repo.ci", "no workflow files found"))
137
+ else:
138
+ records.append(StatusRecord("WARN", "repo.ci", "missing .github/workflows/"))
139
+
140
+ if local_check:
141
+ records.append(StatusRecord("OK", "repo.local_check", local_check))
142
+ else:
143
+ records.append(StatusRecord("WARN", "repo.local_check", "not configured"))
144
+
145
+ if metadata.release is True:
146
+ records.append(StatusRecord("OK", "repo.release", "enabled"))
147
+ elif metadata.release is False:
148
+ records.append(StatusRecord("SKIP", "repo.release", "disabled in metadata"))
149
+ else:
150
+ records.append(StatusRecord("WARN", "repo.release", "not declared in metadata"))
151
+
152
+ return RepoInspectReport(
153
+ repo_root=root,
154
+ repository_name=repository_name,
155
+ repo_type=repo_type,
156
+ metadata=metadata,
157
+ records=records,
158
+ ci_workflows=ci_workflows,
159
+ local_check_command=local_check,
160
+ )
161
+
162
+
163
+ def report_to_dict(report: RepoInspectReport) -> dict[str, Any]:
164
+ """Serialize an inspection report for JSON output."""
165
+ return {
166
+ "repository_name": report.repository_name,
167
+ "repo_type": report.repo_type,
168
+ "repo_root": str(report.repo_root),
169
+ "local_check_command": report.local_check_command,
170
+ "ci_workflows": report.ci_workflows,
171
+ "metadata": {
172
+ "name": report.metadata.name,
173
+ "repo_type": report.metadata.repo_type,
174
+ "release": report.metadata.release,
175
+ "artifacts": report.metadata.artifacts,
176
+ "local_check": report.metadata.local_check,
177
+ "docs_check": report.metadata.docs_check,
178
+ "pr_base": report.metadata.pr_base,
179
+ "ready_labels": list(report.metadata.ready_labels),
180
+ "release_labels": list(report.metadata.release_labels),
181
+ },
182
+ "records": [
183
+ {"level": record.level, "subject": record.subject, "detail": record.detail}
184
+ for record in report.records
185
+ ],
186
+ }
lx_tooling/status.py ADDED
@@ -0,0 +1,35 @@
1
+ """Status vocabulary and line formatting (pure)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ StatusLevel = Literal["OK", "WOULD UPDATE", "UPDATED", "SKIP", "WARN", "ERROR"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class StatusRecord:
13
+ level: StatusLevel
14
+ subject: str
15
+ detail: str
16
+
17
+
18
+ def format_line(record: StatusRecord) -> str:
19
+ """Format one machine-friendly status line (fixed-width level column)."""
20
+ return f"{record.level:<13} {record.subject:<28} {record.detail}"
21
+
22
+
23
+ def exit_code_for_records(records: list[StatusRecord]) -> int:
24
+ """0 unless any ERROR."""
25
+ if any(record.level == "ERROR" for record in records):
26
+ return 1
27
+ return 0
28
+
29
+
30
+ def records_to_dicts(records: list[StatusRecord]) -> list[dict[str, str]]:
31
+ """Serialize status records for JSON output."""
32
+ return [
33
+ {"level": record.level, "subject": record.subject, "detail": record.detail}
34
+ for record in records
35
+ ]
@@ -0,0 +1,31 @@
1
+ """Placeholder for PR and release body templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PR_BODY_TEMPLATE = """## Summary
6
+
7
+ -
8
+
9
+ ## Scope
10
+
11
+ - In scope:
12
+ - Out of scope:
13
+
14
+ ## Verification
15
+
16
+ - [ ] Local checks:
17
+ - [ ] CI:
18
+ - [ ] Docs/examples:
19
+
20
+ ## Release Impact
21
+
22
+ - Version impact:
23
+ - Artifact impact:
24
+ - Deploy impact:
25
+
26
+ ## Links
27
+
28
+ - Issue:
29
+ - ADR:
30
+ - Related PRs:
31
+ """
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: lx-tooling
3
+ Version: 0.1.0
4
+ Summary: Labinetix workflow CLI for humans and AI agents
5
+ Project-URL: Homepage, https://github.com/labinetix/lx-tooling
6
+ Project-URL: Repository, https://github.com/labinetix/lx-tooling
7
+ Project-URL: Documentation, https://github.com/labinetix/lx-tooling/blob/main/docs/design/lx-tooling.md
8
+ Author-email: Fabian Müller <fabianmueller100295@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: typer>=0.26.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # lx-tooling
16
+
17
+ **Tag:** Org and orchestration
18
+
19
+ **Labinetix workflow CLI** for humans and AI agents. `lx-tooling` orchestrates GitHub issues, branches, pull requests, releases, local verification, and repository policy checks without owning domain logic.
20
+
21
+ **Non-goals:** model semantics, ABI schema ownership, runtime algorithms, or protocol implementations.
22
+
23
+ ## Stability
24
+
25
+ Milestone 0 — read-only commands only. Branch creation, PR creation, tags, and releases come in later milestones.
26
+
27
+ ## Quickstart
28
+
29
+ Prerequisites:
30
+
31
+ - Python 3.11+
32
+ - [`uv`](https://docs.astral.sh/uv/)
33
+ - [`gh`](https://cli.github.com/) for `lx issue view`
34
+
35
+ Local development:
36
+
37
+ ```bash
38
+ git clone git@github.com:labinetix/lx-tooling.git
39
+ cd lx-tooling
40
+ uv sync --all-groups
41
+ uv run lx --version
42
+ ```
43
+
44
+ Local checks (same as CI):
45
+
46
+ ```bash
47
+ just check
48
+ ```
49
+
50
+ Or explicitly:
51
+
52
+ ```bash
53
+ uv sync --all-groups
54
+ uv run ruff check .
55
+ uv run ruff format --check .
56
+ uv run pytest
57
+ ```
58
+
59
+ ## Install
60
+
61
+ From PyPI after release:
62
+
63
+ ```bash
64
+ uv tool install lx-tooling
65
+ lx --version
66
+ ```
67
+
68
+ From a checkout:
69
+
70
+ ```bash
71
+ uv sync --all-groups
72
+ uv run lx --help
73
+ ```
74
+
75
+ ## Commands (Milestone 0)
76
+
77
+ Inspect the current repository:
78
+
79
+ ```bash
80
+ lx repo inspect
81
+ lx repo inspect --json
82
+ ```
83
+
84
+ Run conservative pre-PR checks:
85
+
86
+ ```bash
87
+ lx workflow check
88
+ ```
89
+
90
+ Read a GitHub issue with Labinetix readiness hints:
91
+
92
+ ```bash
93
+ gh auth login
94
+ lx issue view 123
95
+ ```
96
+
97
+ See [`docs/examples/repo-inspect.md`](docs/examples/repo-inspect.md) for sample output.
98
+
99
+ ## Design and Agent Rules
100
+
101
+ - Design: [`docs/design/lx-tooling.md`](docs/design/lx-tooling.md)
102
+ - Agent rules: [`AGENTS.md`](AGENTS.md)
103
+
104
+ ## Releases and Artifacts
105
+
106
+ - Package name on PyPI: `lx-tooling`
107
+ - CLI command: `lx`
108
+ - Latest release: see [GitHub Releases](https://github.com/labinetix/lx-tooling/releases)
109
+ - Release artifacts: built by CI on protected SemVer tags (`v*`), published to PyPI via trusted publishing (`pypi.yml`, environment `pypi`)
110
+
111
+ Release checklist for maintainers:
112
+
113
+ 1. Merge changes to `main`
114
+ 2. Tag `v0.y.z` on `main`
115
+ 3. CI builds wheel/sdist, creates GitHub Release, publishes to PyPI
@@ -0,0 +1,17 @@
1
+ lx_tooling/__init__.py,sha256=C0Ztb09zOuQtYFjqt0Vv1qlC1bYb38wIakJx3kMyemw,53
2
+ lx_tooling/cli.py,sha256=s1WNBsndXKwYjZnwKPfUHqq1PdsLfDUh_aedWTKnJ5g,5913
3
+ lx_tooling/git.py,sha256=wpApu6bP0aKidjCdgRjlm9IJjW9R9z7CnUjNOpw2l3o,2460
4
+ lx_tooling/github.py,sha256=TaimLb7qEuXfhNp6KyundDp6bSbDHNAmZMfzGoiE9kk,1261
5
+ lx_tooling/policy.py,sha256=EBKRSa6tlv4_NOl9se4E9j2GqXmCMswFHQlsXE109zU,1677
6
+ lx_tooling/repo.py,sha256=YacecS7s0Y4hbJ2woLXsZSj4uZnC-JNeUCO8fRKBgjs,6788
7
+ lx_tooling/status.py,sha256=m3N5Q_l2ATtsNKM5V-HYtIz-D-d99Wst2wfir-3D9DA,975
8
+ lx_tooling/templates.py,sha256=8qufDtjNi3l7V2nleltIX3U6DI-mZYJXhXmR8t-1bX8,354
9
+ lx_tooling/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ lx_tooling/checks/agents.py,sha256=5xBo-9I6UYZT6u-W_2r-vIZbj8WozG1TB9xf-ZHmhKU,476
11
+ lx_tooling/checks/docs.py,sha256=YjZfCV6mfxVaSn-_na7y71ry1VTD1Z_x2LiEGkfnnzY,468
12
+ lx_tooling/checks/workflow.py,sha256=6lwLzhh0XuDXwSsXUWFSa0NxvMKdP-5KROpdQ549Eew,2877
13
+ lx_tooling-0.1.0.dist-info/METADATA,sha256=ADfyNzrIDtqmWie1dA-N6_HdEF0F-DP278m1fK36Rvc,2593
14
+ lx_tooling-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ lx_tooling-0.1.0.dist-info/entry_points.txt,sha256=AFtAryVAEg7MYRwfNQsg2sYTVagWWrYDO6Gwe_uHG_8,42
16
+ lx_tooling-0.1.0.dist-info/licenses/LICENSE,sha256=jBlc-TeGkNEync1zjyUmk-jnVPBhD9FqDbSEy31fZHw,1066
17
+ lx_tooling-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lx = lx_tooling.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Labinetix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.