aegisure 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. aegisure-0.1.0/.gitignore +20 -0
  2. aegisure-0.1.0/PKG-INFO +36 -0
  3. aegisure-0.1.0/README.md +12 -0
  4. aegisure-0.1.0/pyproject.toml +39 -0
  5. aegisure-0.1.0/src/aegisure/__init__.py +1 -0
  6. aegisure-0.1.0/src/aegisure/agent_failure_memory.py +51 -0
  7. aegisure-0.1.0/src/aegisure/agent_memory_export.py +140 -0
  8. aegisure-0.1.0/src/aegisure/attribution.py +83 -0
  9. aegisure-0.1.0/src/aegisure/cli.py +182 -0
  10. aegisure-0.1.0/src/aegisure/constitution.py +268 -0
  11. aegisure-0.1.0/src/aegisure/cost_router.py +89 -0
  12. aegisure-0.1.0/src/aegisure/crypto_identity.py +241 -0
  13. aegisure-0.1.0/src/aegisure/diff_parser.py +184 -0
  14. aegisure-0.1.0/src/aegisure/diff_risk.py +130 -0
  15. aegisure-0.1.0/src/aegisure/finding_dismissals.py +36 -0
  16. aegisure-0.1.0/src/aegisure/llm_provider.py +189 -0
  17. aegisure-0.1.0/src/aegisure/memory_timeline.py +57 -0
  18. aegisure-0.1.0/src/aegisure/policy_engine.py +112 -0
  19. aegisure-0.1.0/src/aegisure/privacy.py +89 -0
  20. aegisure-0.1.0/src/aegisure/provenance.py +93 -0
  21. aegisure-0.1.0/src/aegisure/repair_prompt.py +74 -0
  22. aegisure-0.1.0/src/aegisure/safety.py +86 -0
  23. aegisure-0.1.0/src/aegisure/second_opinion.py +57 -0
  24. aegisure-0.1.0/src/aegisure/state.py +75 -0
  25. aegisure-0.1.0/src/aegisure/storage/__init__.py +0 -0
  26. aegisure-0.1.0/src/aegisure/storage/db.py +77 -0
  27. aegisure-0.1.0/src/aegisure/storage/profile_paths.py +17 -0
  28. aegisure-0.1.0/src/aegisure_github/__init__.py +0 -0
  29. aegisure-0.1.0/src/aegisure_github/client.py +119 -0
  30. aegisure-0.1.0/src/aegisure_github/models.py +114 -0
  31. aegisure-0.1.0/src/aegisure_github/pr_flow.py +112 -0
  32. aegisure-0.1.0/src/aegisure_github/webhooks.py +61 -0
  33. aegisure-0.1.0/tests/test_core.py +64 -0
  34. aegisure-0.1.0/tests/test_github_flow.py +70 -0
  35. aegisure-0.1.0/tests/test_pivot_m1_core.py +84 -0
  36. aegisure-0.1.0/tests/test_pivot_m2_memory_provenance.py +87 -0
  37. aegisure-0.1.0/tests/test_pivot_m3_github_app.py +70 -0
  38. aegisure-0.1.0/tests/test_pivot_m4_intelligence.py +61 -0
  39. aegisure-0.1.0/tests/test_pivot_n2_llm_cost_memory.py +54 -0
  40. aegisure-0.1.0/tests/test_pivot_n4_github_pr_flow.py +63 -0
  41. aegisure-0.1.0/tests/test_pivot_n6_false_positive.py +20 -0
@@ -0,0 +1,20 @@
1
+ .env
2
+ .env.local
3
+ .venv/
4
+ venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .mypy_cache/
14
+ node_modules/
15
+ .next/
16
+ coverage/
17
+ .aegisure/
18
+ *.sqlite3
19
+ *.db
20
+ .DS_Store
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: aegisure
3
+ Version: 0.1.0
4
+ Summary: Aegisure CLI and core engine for governing AI coding agents.
5
+ Project-URL: Homepage, https://aegisure.dev
6
+ Project-URL: Repository, https://github.com/Hetul803/Aegisure
7
+ Project-URL: Documentation, https://aegisure.dev/docs
8
+ Author: Aegisure
9
+ License: Proprietary
10
+ Keywords: ai-agents,developer-tools,github,provenance,security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: cryptography>=42.0.0
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: pyyaml>=6.0.1
22
+ Requires-Dist: typer>=0.12.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Aegisure CLI
26
+
27
+ Aegisure is the control and audit plane for AI coding agents. The CLI gives a local-first path for generating a repo Constitution, scanning diffs, exporting cross-agent memory files, capturing provenance, and preparing repair prompts.
28
+
29
+ ```bash
30
+ pip install aegisure
31
+ aegisure init
32
+ aegisure scan --staged
33
+ aegisure export
34
+ ```
35
+
36
+ The static scanner is fully offline and does not call an LLM. Optional review features can use Anthropic, OpenAI, or Ollama when configured.
@@ -0,0 +1,12 @@
1
+ # Aegisure CLI
2
+
3
+ Aegisure is the control and audit plane for AI coding agents. The CLI gives a local-first path for generating a repo Constitution, scanning diffs, exporting cross-agent memory files, capturing provenance, and preparing repair prompts.
4
+
5
+ ```bash
6
+ pip install aegisure
7
+ aegisure init
8
+ aegisure scan --staged
9
+ aegisure export
10
+ ```
11
+
12
+ The static scanner is fully offline and does not call an LLM. Optional review features can use Anthropic, OpenAI, or Ollama when configured.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aegisure"
7
+ version = "0.1.0"
8
+ description = "Aegisure CLI and core engine for governing AI coding agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Aegisure" }]
13
+ keywords = ["ai-agents", "github", "security", "provenance", "developer-tools"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+ dependencies = [
24
+ "cryptography>=42.0.0",
25
+ "httpx>=0.27.0",
26
+ "pyyaml>=6.0.1",
27
+ "typer>=0.12.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://aegisure.dev"
32
+ Repository = "https://github.com/Hetul803/Aegisure"
33
+ Documentation = "https://aegisure.dev/docs"
34
+
35
+ [project.scripts]
36
+ aegisure = "aegisure.cli:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/aegisure", "src/aegisure_github"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AgentFailureRecord:
11
+ repo: str
12
+ agent: str
13
+ failure_class: str
14
+ summary: str
15
+ repair_worked: bool | None = None
16
+ created_at: str = ""
17
+
18
+ def to_dict(self) -> dict:
19
+ return {
20
+ "repo": self.repo,
21
+ "agent": self.agent,
22
+ "failure_class": self.failure_class,
23
+ "summary": self.summary,
24
+ "repair_worked": self.repair_worked,
25
+ "created_at": self.created_at or datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
26
+ }
27
+
28
+
29
+ def record_agent_failure(repo_path: str | Path, record: AgentFailureRecord) -> Path:
30
+ target_dir = Path(repo_path).resolve() / ".aegisure"
31
+ target_dir.mkdir(parents=True, exist_ok=True)
32
+ target = target_dir / "agent-failure-memory.jsonl"
33
+ with target.open("a", encoding="utf-8") as handle:
34
+ handle.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
35
+ return target
36
+
37
+
38
+ def list_agent_failures(repo_path: str | Path, *, agent: str | None = None) -> list[dict]:
39
+ target = Path(repo_path).resolve() / ".aegisure" / "agent-failure-memory.jsonl"
40
+ if not target.exists():
41
+ return []
42
+ rows = []
43
+ for line in target.read_text(encoding="utf-8").splitlines():
44
+ try:
45
+ row = json.loads(line)
46
+ except Exception:
47
+ continue
48
+ if agent and row.get("agent") != agent:
49
+ continue
50
+ rows.append(row)
51
+ return rows
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .constitution import Constitution, constitution_for_repo, render_constitution
6
+
7
+
8
+ EXPORT_TARGETS = (
9
+ "AEGIS.md",
10
+ "AGENTS.md",
11
+ "CLAUDE.md",
12
+ ".cursorrules",
13
+ ".clinerules",
14
+ ".github/copilot-instructions.md",
15
+ )
16
+
17
+
18
+ def _rules_block(constitution: Constitution) -> str:
19
+ return "\n".join(f"- {rule}" for rule in constitution.agent_rules)
20
+
21
+
22
+ def _approval_block(constitution: Constitution) -> str:
23
+ return "\n".join(f"- {rule}" for rule in constitution.approval_rules)
24
+
25
+
26
+ def _test_block(constitution: Constitution) -> str:
27
+ return "\n".join(f"- `{command}`" for command in constitution.test_commands) or "- No test command detected yet."
28
+
29
+
30
+ def _protected_block(constitution: Constitution) -> str:
31
+ return "\n".join(f"- `{path}`" for path in constitution.protected_paths) or "- No protected paths detected yet."
32
+
33
+
34
+ def build_memory_exports(constitution: Constitution) -> dict[str, str]:
35
+ shared = {
36
+ "repo": constitution.repo_name,
37
+ "summary": constitution.summary,
38
+ "rules": _rules_block(constitution),
39
+ "approval": _approval_block(constitution),
40
+ "tests": _test_block(constitution),
41
+ "protected": _protected_block(constitution),
42
+ }
43
+ aura_md = render_constitution(constitution)
44
+ agent_md = f"""# Agent Instructions for {shared['repo']}
45
+
46
+ {shared['summary']}
47
+
48
+ ## Required Behavior
49
+ {shared['rules']}
50
+
51
+ ## Human Approval Required
52
+ {shared['approval']}
53
+
54
+ ## Protected Paths
55
+ {shared['protected']}
56
+
57
+ ## Verification
58
+ {shared['tests']}
59
+ """
60
+ claude_md = f"""# Claude Code Memory
61
+
62
+ You are working in `{shared['repo']}`.
63
+
64
+ Project summary: {shared['summary']}
65
+
66
+ Follow these non-negotiable rules:
67
+ {shared['rules']}
68
+
69
+ Before editing protected areas, ask for human review:
70
+ {shared['protected']}
71
+
72
+ Run or recommend these checks:
73
+ {shared['tests']}
74
+ """
75
+ cursor_rules = f"""You are an AI coding agent in {shared['repo']}.
76
+
77
+ {shared['summary']}
78
+
79
+ Rules:
80
+ {shared['rules']}
81
+
82
+ Protected paths:
83
+ {shared['protected']}
84
+
85
+ Tests:
86
+ {shared['tests']}
87
+ """
88
+ cline_rules = f"""# Cline/Roo Rules
89
+
90
+ Repository: {shared['repo']}
91
+
92
+ {shared['summary']}
93
+
94
+ Do:
95
+ {shared['rules']}
96
+
97
+ Require approval:
98
+ {shared['approval']}
99
+
100
+ Verify with:
101
+ {shared['tests']}
102
+ """
103
+ copilot = f"""# GitHub Copilot Instructions
104
+
105
+ This repository uses Aegisure as its project Constitution.
106
+
107
+ Summary: {shared['summary']}
108
+
109
+ Coding rules:
110
+ {shared['rules']}
111
+
112
+ Protected paths:
113
+ {shared['protected']}
114
+
115
+ Verification:
116
+ {shared['tests']}
117
+ """
118
+ return {
119
+ "AEGIS.md": aura_md,
120
+ "AGENTS.md": agent_md,
121
+ "CLAUDE.md": claude_md,
122
+ ".cursorrules": cursor_rules,
123
+ ".clinerules": cline_rules,
124
+ ".github/copilot-instructions.md": copilot,
125
+ }
126
+
127
+
128
+ def write_memory_exports(repo_path: str | Path, *, overwrite: bool = True) -> list[dict[str, str | bool]]:
129
+ repo = Path(repo_path).resolve()
130
+ exports = build_memory_exports(constitution_for_repo(repo))
131
+ results: list[dict[str, str | bool]] = []
132
+ for target, content in exports.items():
133
+ path = repo / target
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ previous = path.read_text(encoding="utf-8") if path.exists() else None
136
+ changed = previous != content
137
+ if changed and (overwrite or previous is None):
138
+ path.write_text(content, encoding="utf-8")
139
+ results.append({"path": str(path), "target": target, "changed": changed})
140
+ return results
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from .diff_parser import ParsedDiff
10
+
11
+
12
+ KNOWN_AGENTS = {
13
+ "codex": ("codex", "openai codex"),
14
+ "claude-code": ("claude", "claude code"),
15
+ "cursor": ("cursor",),
16
+ "copilot": ("copilot", "github copilot"),
17
+ "cline": ("cline",),
18
+ "roo": ("roo", "roo code"),
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AttributionRecord:
24
+ repo: str
25
+ change_id: str
26
+ path: str
27
+ agent: str
28
+ source: str
29
+ created_at: str
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "repo": self.repo,
34
+ "change_id": self.change_id,
35
+ "path": self.path,
36
+ "agent": self.agent,
37
+ "source": self.source,
38
+ "created_at": self.created_at,
39
+ }
40
+
41
+
42
+ def infer_agent(*, commit_message: str = "", pr_body: str = "", explicit_agent: str | None = None) -> str:
43
+ if explicit_agent:
44
+ return explicit_agent
45
+ text = f"{commit_message}\n{pr_body}".lower()
46
+ trailer = re.search(r"aura-agent:\s*([a-z0-9_.-]+)", text)
47
+ if trailer:
48
+ return trailer.group(1)
49
+ for agent, needles in KNOWN_AGENTS.items():
50
+ if any(needle in text for needle in needles):
51
+ return agent
52
+ return "unknown"
53
+
54
+
55
+ def attribution_records(parsed: ParsedDiff, *, repo: str, change_id: str, agent: str, source: str = "analysis") -> list[AttributionRecord]:
56
+ now = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
57
+ return [AttributionRecord(repo=repo, change_id=change_id, path=file.path, agent=agent, source=source, created_at=now) for file in parsed.files]
58
+
59
+
60
+ def append_attribution_ledger(repo_path: str | Path, records: list[AttributionRecord]) -> Path:
61
+ aegisure_dir = Path(repo_path).resolve() / ".aegisure"
62
+ aegisure_dir.mkdir(parents=True, exist_ok=True)
63
+ target = aegisure_dir / "attribution-ledger.jsonl"
64
+ with target.open("a", encoding="utf-8") as handle:
65
+ for record in records:
66
+ handle.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
67
+ return target
68
+
69
+
70
+ def query_attribution_ledger(repo_path: str | Path, *, agent: str | None = None) -> list[dict]:
71
+ target = Path(repo_path).resolve() / ".aegisure" / "attribution-ledger.jsonl"
72
+ if not target.exists():
73
+ return []
74
+ rows = []
75
+ for line in target.read_text(encoding="utf-8").splitlines():
76
+ try:
77
+ row = json.loads(line)
78
+ except Exception:
79
+ continue
80
+ if agent and row.get("agent") != agent:
81
+ continue
82
+ rows.append(row)
83
+ return rows
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from .agent_memory_export import write_memory_exports
12
+ from .attribution import append_attribution_ledger, attribution_records, infer_agent
13
+ from .constitution import write_constitution, constitution_for_repo
14
+ from .diff_parser import parse_unified_diff
15
+ from .diff_risk import analyze_diff
16
+ from .policy_engine import default_policy_yaml, evaluate_policy
17
+ from .provenance import build_commit_message, prompt_hash, read_commit_provenance, record_git_note
18
+ from .repair_prompt import generate_repair_prompt
19
+ from .second_opinion import cross_model_second_opinion, heuristic_second_opinion
20
+
21
+ app = typer.Typer(help="Aegisure: control and audit plane for AI coding agents.")
22
+
23
+
24
+ def _repo(path: str | Path = ".") -> Path:
25
+ return Path(path).resolve()
26
+
27
+
28
+ def _selected_repo(repo: Path | None, path: Path | None) -> Path:
29
+ return _repo(path or repo or ".")
30
+
31
+
32
+ def _run_git(repo: Path, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
33
+ proc = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True)
34
+ if check and proc.returncode != 0:
35
+ raise typer.BadParameter(proc.stderr.strip() or proc.stdout.strip() or f"git {' '.join(args)} failed")
36
+ return proc
37
+
38
+
39
+ def _diff_text(repo: Path, *, staged: bool = False, base: Optional[str] = None) -> str:
40
+ if base:
41
+ return _run_git(repo, ["diff", base]).stdout
42
+ if staged:
43
+ return _run_git(repo, ["diff", "--cached"]).stdout
44
+ text = _run_git(repo, ["diff"]).stdout
45
+ return text or _run_git(repo, ["diff", "--cached"]).stdout
46
+
47
+
48
+ @app.command()
49
+ def init(
50
+ repo: Path | None = typer.Argument(None, help="Repository to scan."),
51
+ path: Path | None = typer.Option(None, "--path", help="Repository to scan."),
52
+ overwrite: bool = typer.Option(False, help="Overwrite existing AEGIS.md."),
53
+ ) -> None:
54
+ """Scan a repository and generate the canonical AEGIS.md Constitution."""
55
+
56
+ target = write_constitution(_selected_repo(repo, path), overwrite=overwrite)
57
+ typer.echo(f"Generated {target}")
58
+
59
+
60
+ @app.command()
61
+ def export(
62
+ repo: Path | None = typer.Argument(None, help="Repository to export memory files into."),
63
+ path: Path | None = typer.Option(None, "--path", help="Repository to export memory files into."),
64
+ ) -> None:
65
+ """Export AEGIS.md into standard agent memory files."""
66
+
67
+ results = write_memory_exports(_selected_repo(repo, path), overwrite=True)
68
+ for result in results:
69
+ marker = "updated" if result["changed"] else "unchanged"
70
+ typer.echo(f"{marker}: {result['target']}")
71
+
72
+
73
+ @app.command()
74
+ def scan(
75
+ repo: Path | None = typer.Argument(None, help="Repository to scan."),
76
+ path: Path | None = typer.Option(None, "--path", help="Repository to scan."),
77
+ staged: bool = typer.Option(False, "--staged", help="Analyze staged changes."),
78
+ base: Optional[str] = typer.Option(None, "--base", help="Git ref to diff against."),
79
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
80
+ ) -> None:
81
+ """Analyze a local diff using the LLM-free static core."""
82
+
83
+ repo_path = _selected_repo(repo, path)
84
+ diff = _diff_text(repo_path, staged=staged, base=base)
85
+ constitution = constitution_for_repo(repo_path)
86
+ report = analyze_diff(diff, constitution=constitution)
87
+ policy = evaluate_policy(diff, policy_text=default_policy_yaml(), risk_report=report)
88
+ payload = {**report.to_dict(), "policy_evaluation": policy.to_dict()}
89
+ if json_output:
90
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
91
+ if report.verdict == "block":
92
+ raise typer.Exit(1)
93
+ return
94
+ typer.echo(f"Aegisure verdict: {report.verdict} ({report.score}/100)")
95
+ typer.echo(report.summary)
96
+ for finding in report.findings:
97
+ location = f"{finding.path}:{finding.line}" if finding.line else finding.path
98
+ typer.echo(f"- {finding.severity.upper()} {finding.category} at {location}: {finding.explanation}")
99
+ if not policy.passed:
100
+ typer.echo("Policy violations:")
101
+ for violation in policy.violations:
102
+ typer.echo(f"- {violation.severity.upper()} {violation.rule_id}: {violation.explanation}")
103
+ if report.verdict == "block":
104
+ raise typer.Exit(1)
105
+
106
+
107
+ @app.command()
108
+ def repair(
109
+ repo: Path | None = typer.Argument(None, help="Repository to inspect."),
110
+ path: Path | None = typer.Option(None, "--path", help="Repository to inspect."),
111
+ staged: bool = typer.Option(False, "--staged", help="Use staged diff."),
112
+ agent: str = typer.Option("codex", "--agent", help="Target agent for the repair prompt."),
113
+ ) -> None:
114
+ """Generate a constrained repair prompt for the current risky diff."""
115
+
116
+ repo_path = _selected_repo(repo, path)
117
+ diff = _diff_text(repo_path, staged=staged)
118
+ constitution = constitution_for_repo(repo_path)
119
+ report = analyze_diff(diff, constitution=constitution)
120
+ prompt = generate_repair_prompt(risk_report=report, constitution=constitution, agent=agent)
121
+ typer.echo(prompt.prompt)
122
+
123
+
124
+ @app.command()
125
+ def review(
126
+ repo: Path | None = typer.Argument(None, help="Repository to inspect."),
127
+ path: Path | None = typer.Option(None, "--path", help="Repository to inspect."),
128
+ staged: bool = typer.Option(False, "--staged", help="Use staged diff."),
129
+ provider: str = typer.Option("static", "--provider", help="static, anthropic, openai, or ollama."),
130
+ ) -> None:
131
+ """Run a second opinion on the current diff."""
132
+
133
+ diff = _diff_text(_selected_repo(repo, path), staged=staged)
134
+ if provider == "static":
135
+ opinion = heuristic_second_opinion(diff)
136
+ else:
137
+ opinion = asyncio.run(cross_model_second_opinion(diff, author_agent="unknown", reviewer=provider))
138
+ typer.echo(json.dumps(opinion.to_dict(), indent=2, sort_keys=True))
139
+
140
+
141
+ @app.command()
142
+ def commit(
143
+ message: str = typer.Option(..., "-m", "--message", help="Commit message."),
144
+ agent: str = typer.Option(..., "--agent", help="Agent name: codex, claude-code, cursor, copilot, cline, roo, human."),
145
+ prompt: str = typer.Option(..., "--prompt", help="Prompt that produced the change."),
146
+ repo: Path | None = typer.Option(None, "--repo", help="Repository to commit."),
147
+ path: Path | None = typer.Option(None, "--path", help="Repository to commit."),
148
+ ) -> None:
149
+ """Create a git commit and capture provenance + attribution."""
150
+
151
+ repo_path = _selected_repo(repo, path)
152
+ final_message = build_commit_message(message, agent=agent, prompt=prompt)
153
+ _run_git(repo_path, ["commit", "-m", final_message])
154
+ sha = _run_git(repo_path, ["rev-parse", "HEAD"]).stdout.strip()
155
+ record = read_commit_provenance(repo_path, sha)
156
+ if record:
157
+ record_git_note(repo_path, sha, record)
158
+ diff = _run_git(repo_path, ["show", "--format=", "--find-renames", sha]).stdout
159
+ parsed = parse_unified_diff(diff)
160
+ ledger = attribution_records(parsed, repo=repo_path.name, change_id=prompt_hash(prompt), agent=infer_agent(explicit_agent=agent), source="cli_commit")
161
+ append_attribution_ledger(repo_path, ledger)
162
+ typer.echo(f"Committed {sha} and recorded {len(ledger)} attribution entries.")
163
+
164
+
165
+ @app.command()
166
+ def login(workspace: str = typer.Option("local", "--workspace", help="Workspace id or slug."), token: str = typer.Option("", "--token", help="Optional dashboard API token.")) -> None:
167
+ """Store local workspace login metadata for CLI use."""
168
+
169
+ from .state import record_audit_event
170
+ from .storage.db import init_db
171
+
172
+ init_db()
173
+ record_audit_event({"workspace_id": workspace, "event_type": "cli_login", "message": "CLI workspace login configured.", "payload": {"token_configured": bool(token)}})
174
+ typer.echo(f"CLI configured for workspace `{workspace}`. Static scans remain local-first.")
175
+
176
+
177
+ def main() -> None:
178
+ app()
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()