agentcam 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.
agentcam/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """agentcam: local-first CLI wrapper that records agent runs."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
agentcam/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """agentcam command-line entry point.
2
+
3
+ Subcommands:
4
+ - ``agentcam version`` — print version and exit
5
+ - ``agentcam run -- <argv...>`` — wrap a command, record raw + redacted
6
+ logs, generate AGENT_RUN_REPORT.md
7
+
8
+ ``run`` is intentionally argv-only; for shell features (pipes, redirects,
9
+ variable expansion) wrap your own shell explicitly, e.g.::
10
+
11
+ agentcam run -- bash -lc "echo hi > out.txt"
12
+ agentcam run -- pwsh -Command "Get-Process | Out-File procs.txt"
13
+ agentcam run -- cmd /c "dir > files.txt"
14
+
15
+ See ``docs/design.md`` (forthcoming) for the rationale.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import platform
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from agentcam import __version__
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="agentcam",
31
+ description=(
32
+ "Local-first CLI wrapper that records what your AI coding agent "
33
+ "changed in your repo."
34
+ ),
35
+ )
36
+ sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
37
+
38
+ sub.add_parser("version", help="Print agentcam version and exit.")
39
+
40
+ run = sub.add_parser(
41
+ "run",
42
+ help="Wrap a command and record the agent run.",
43
+ description=(
44
+ "Wraps an argv-style command. Use `bash -lc \"...\"`, "
45
+ "`pwsh -Command \"...\"`, or `cmd /c \"...\"` for shell features "
46
+ "(pipes, redirects, variable expansion)."
47
+ ),
48
+ )
49
+ run.add_argument(
50
+ "--name",
51
+ default=None,
52
+ help="Slug included in the run id (e.g. 'claude-fix-login').",
53
+ )
54
+ run.add_argument(
55
+ "argv",
56
+ nargs=argparse.REMAINDER,
57
+ help="The command to run, after a `--` separator.",
58
+ )
59
+
60
+ return parser
61
+
62
+
63
+ def _strip_leading_dashdash(argv: list[str]) -> list[str]:
64
+ """argparse.REMAINDER keeps a leading `--`; strip it for cleanliness."""
65
+ if argv and argv[0] == "--":
66
+ return argv[1:]
67
+ return argv
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ parser = build_parser()
72
+ args = parser.parse_args(argv)
73
+
74
+ if args.cmd == "version":
75
+ print(f"agentcam {__version__}")
76
+ return 0
77
+
78
+ if args.cmd == "run":
79
+ return _run_command(args)
80
+
81
+ parser.error(f"unknown subcommand: {args.cmd}")
82
+ return 2 # unreachable; parser.error exits
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # `agentcam run` orchestrator
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _run_command(args) -> int:
90
+ # Imports are local so `agentcam version` doesn't pay for them at startup.
91
+ from agentcam.git_state import (
92
+ NotAGitRepoError,
93
+ collect_git_state,
94
+ is_working_tree_dirty,
95
+ resolve_git_dir,
96
+ resolve_git_root,
97
+ )
98
+ from agentcam.models import RunManifest
99
+ from agentcam.paths import RunIdCollisionError, create_run_dir
100
+ from agentcam.redaction import StreamingRedactor, redact_argv
101
+ from agentcam.report import render_report, write_manifest
102
+ from agentcam.runner import CommandNotFoundError, run_wrapped
103
+ from agentcam.scanner import scan_output, scan_paths
104
+
105
+ run_argv = _strip_leading_dashdash(args.argv or [])
106
+ if not run_argv:
107
+ print(
108
+ "agentcam run: no command provided. "
109
+ "Usage: agentcam run -- <command...>",
110
+ file=sys.stderr,
111
+ )
112
+ return 2
113
+
114
+ cwd = Path.cwd()
115
+
116
+ # 1) Confirm we're in a git repo and resolve git dir.
117
+ try:
118
+ git_dir = resolve_git_dir(cwd)
119
+ git_root = resolve_git_root(cwd)
120
+ except NotAGitRepoError:
121
+ print(
122
+ "agentcam: not in a git repository. "
123
+ "Initialize one with 'git init' first.",
124
+ file=sys.stderr,
125
+ )
126
+ return 2
127
+ except Exception as e: # noqa: BLE001
128
+ print(f"agentcam: git error: {e}", file=sys.stderr)
129
+ return 2
130
+
131
+ # 2) Collect pre-run git state.
132
+ try:
133
+ state_before = collect_git_state(cwd, is_after=False)
134
+ except NotAGitRepoError:
135
+ print(
136
+ "agentcam: not in a git repository. "
137
+ "Initialize one with 'git init' first.",
138
+ file=sys.stderr,
139
+ )
140
+ return 2
141
+ pre_run_dirty = is_working_tree_dirty(state_before)
142
+
143
+ # 3) Create the run directory under <git_dir>/agentcam/runs/<run_id>/.
144
+ started_at = datetime.now(timezone.utc).astimezone()
145
+ try:
146
+ run_id, run_paths = create_run_dir(
147
+ git_dir, started_at, name=args.name
148
+ )
149
+ except RunIdCollisionError as e:
150
+ print(f"agentcam: {e}", file=sys.stderr)
151
+ return 2
152
+
153
+ # 4) Run the wrapped subprocess with threads-based tee.
154
+ try:
155
+ run_result = run_wrapped(
156
+ run_argv,
157
+ cwd=cwd,
158
+ stdout_raw_path=Path(run_paths.stdout_raw),
159
+ stderr_raw_path=Path(run_paths.stderr_raw),
160
+ )
161
+ except CommandNotFoundError as e:
162
+ print(str(e), file=sys.stderr)
163
+ return 2
164
+
165
+ ended_at = datetime.now(timezone.utc).astimezone()
166
+ duration = (ended_at - started_at).total_seconds()
167
+
168
+ # 5) Produce redacted logs from the raw logs.
169
+ _redact_log(Path(run_paths.stdout_raw), Path(run_paths.stdout_redacted))
170
+ _redact_log(Path(run_paths.stderr_raw), Path(run_paths.stderr_redacted))
171
+
172
+ # 6) Collect post-run git state (is_after=True triggers diff --check).
173
+ state_after = collect_git_state(cwd, is_after=True)
174
+
175
+ # 7) Scan paths + raw output for risk flags.
176
+ risk_flags = scan_paths(state_after.changed_files)
177
+ risk_flags.extend(_scan_log(Path(run_paths.stdout_raw), "stdout.log"))
178
+ risk_flags.extend(_scan_log(Path(run_paths.stderr_raw), "stderr.log"))
179
+
180
+ # 8) Assemble the manifest.
181
+ manifest = RunManifest(
182
+ schema_version="0.1",
183
+ run_id=run_id.text,
184
+ started_at=started_at,
185
+ ended_at=ended_at,
186
+ duration_seconds=duration,
187
+ cwd=str(cwd),
188
+ git_root=str(git_root),
189
+ git_dir=str(git_dir),
190
+ branch=state_before.branch,
191
+ is_detached_head=state_before.is_detached_head,
192
+ head_before=state_before.head,
193
+ head_after=state_after.head,
194
+ pre_existing_op=(
195
+ state_before.pre_existing_op or state_after.pre_existing_op
196
+ ),
197
+ pre_run_dirty=pre_run_dirty,
198
+ command_argv_raw=list(run_argv),
199
+ command_argv_redacted=redact_argv(list(run_argv)),
200
+ exit_detail=run_result.exit_detail,
201
+ shell_used=run_result.shell_used,
202
+ terminal_forward_degraded=run_result.terminal_forward_degraded,
203
+ platform=platform.system().lower(),
204
+ agentcam_version=__version__,
205
+ paths=run_paths,
206
+ )
207
+
208
+ # 9) Write report + manifest.
209
+ Path(run_paths.report_md).write_text(
210
+ render_report(manifest, state_before, state_after, risk_flags),
211
+ encoding="utf-8",
212
+ )
213
+ write_manifest(manifest, Path(run_paths.manifest_json))
214
+
215
+ # 10) Tell the user where to find the report (stderr so it doesn't pollute
216
+ # programmatic stdout consumers).
217
+ print(
218
+ f"\nagentcam: run report at {run_paths.report_md}",
219
+ file=sys.stderr,
220
+ )
221
+
222
+ # 11) Return the wrapper exit code (0 if subprocess succeeded, else 1).
223
+ return run_result.exit_detail.wrapper_exit
224
+
225
+
226
+ def _redact_log(raw_path: Path, redacted_path: Path) -> None:
227
+ """Stream raw_path through StreamingRedactor into redacted_path."""
228
+ from agentcam.redaction import StreamingRedactor
229
+
230
+ with raw_path.open("rb") as in_fp, redacted_path.open("wb") as out_fp:
231
+ r = StreamingRedactor(out_fp)
232
+ while True:
233
+ chunk = in_fp.read(4096)
234
+ if not chunk:
235
+ break
236
+ r.feed(chunk)
237
+ r.close()
238
+
239
+
240
+ def _scan_log(raw_path: Path, label: str):
241
+ """Scan a raw log for output-pattern risk flags."""
242
+ from agentcam.scanner import scan_output
243
+
244
+ try:
245
+ text = raw_path.read_bytes().decode("utf-8", errors="replace")
246
+ except OSError:
247
+ text = ""
248
+ return scan_output(text, stream_label=label)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
agentcam/git_state.py ADDED
@@ -0,0 +1,207 @@
1
+ """Git state collection (before and after the wrapped command).
2
+
3
+ Uses ``git status --porcelain=v1 -z`` as the primary source of truth, with
4
+ ``git diff [--cached] --stat / --name-status / --check`` for display in the
5
+ report. See plan section 4.
6
+
7
+ ``git_dir`` is resolved via ``git rev-parse --git-dir`` so worktree and
8
+ submodule gitlink cases (where ``<repo>/.git`` is a file, not a directory)
9
+ work correctly. See plan section 1.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from agentcam.models import ChangedFile, ChangeStatus, GitState
17
+
18
+ # Order matters: the first matching marker wins. ``rebase-merge`` and
19
+ # ``rebase-apply`` are checked before ``REVERT_HEAD`` etc.
20
+ _PRE_EXISTING_OP_MARKERS: tuple[tuple[str, str], ...] = (
21
+ ("MERGE_HEAD", "merge"),
22
+ ("rebase-merge", "rebase"),
23
+ ("rebase-apply", "rebase"),
24
+ ("CHERRY_PICK_HEAD", "cherry-pick"),
25
+ ("REVERT_HEAD", "revert"),
26
+ ("BISECT_LOG", "bisect"),
27
+ )
28
+
29
+
30
+ class NotAGitRepoError(RuntimeError):
31
+ """Raised when the cwd is not inside a git repository."""
32
+
33
+
34
+ def is_git_repo(cwd: Path) -> bool:
35
+ return _git(cwd, "rev-parse", "--git-dir", check=False).returncode == 0
36
+
37
+
38
+ def resolve_git_dir(cwd: Path) -> Path:
39
+ """Absolute path of the real git directory.
40
+
41
+ ``git rev-parse --git-dir`` resolves worktree / submodule gitlink files
42
+ for us, so we never have to read or parse ``<repo>/.git`` ourselves.
43
+ """
44
+ text = _git_text(cwd, "rev-parse", "--git-dir")
45
+ p = Path(text)
46
+ if not p.is_absolute():
47
+ p = (cwd / p).resolve()
48
+ return p
49
+
50
+
51
+ def resolve_git_root(cwd: Path) -> Path:
52
+ """Absolute path of the working tree root."""
53
+ return Path(_git_text(cwd, "rev-parse", "--show-toplevel"))
54
+
55
+
56
+ def detect_pre_existing_op(git_dir: Path) -> str | None:
57
+ """Return operation name (merge / rebase / cherry-pick / etc.) or None."""
58
+ for filename, op in _PRE_EXISTING_OP_MARKERS:
59
+ if (git_dir / filename).exists():
60
+ return op
61
+ return None
62
+
63
+
64
+ def collect_git_state(cwd: Path, *, is_after: bool = False) -> GitState:
65
+ """Snapshot git state. ``is_after=True`` also runs ``git diff --check``."""
66
+ if not is_git_repo(cwd):
67
+ raise NotAGitRepoError(
68
+ "Not in a git repository. Initialize one with 'git init' first."
69
+ )
70
+
71
+ git_dir = resolve_git_dir(cwd)
72
+
73
+ head = _safe_head(cwd)
74
+ branch_raw = _git_text(cwd, "branch", "--show-current")
75
+ branch = branch_raw or None
76
+ is_detached = head is not None and not branch
77
+
78
+ porcelain_raw = _git(cwd, "status", "--porcelain=v1", "-z").stdout
79
+ diff_stat = _git_text(cwd, "diff", "--stat", check=False)
80
+ diff_stat_cached = _git_text(cwd, "diff", "--cached", "--stat", check=False)
81
+ diff_name_status = _git_text(cwd, "diff", "--name-status", check=False)
82
+ diff_name_status_cached = _git_text(
83
+ cwd, "diff", "--cached", "--name-status", check=False
84
+ )
85
+
86
+ diff_check = ""
87
+ diff_check_cached = ""
88
+ if is_after:
89
+ diff_check = _git_text(cwd, "diff", "--check", check=False)
90
+ diff_check_cached = _git_text(
91
+ cwd, "diff", "--cached", "--check", check=False
92
+ )
93
+
94
+ pre_existing_op = detect_pre_existing_op(git_dir)
95
+ changed_files = parse_porcelain_v1z(porcelain_raw)
96
+
97
+ return GitState(
98
+ head=head,
99
+ branch=branch,
100
+ is_detached_head=is_detached,
101
+ porcelain_raw=porcelain_raw,
102
+ diff_stat=diff_stat,
103
+ diff_stat_cached=diff_stat_cached,
104
+ diff_name_status=diff_name_status,
105
+ diff_name_status_cached=diff_name_status_cached,
106
+ diff_check=diff_check,
107
+ diff_check_cached=diff_check_cached,
108
+ pre_existing_op=pre_existing_op,
109
+ changed_files=changed_files,
110
+ )
111
+
112
+
113
+ def is_working_tree_dirty(state: GitState) -> bool:
114
+ """True if there are any staged, unstaged, or untracked changes."""
115
+ return bool(state.changed_files)
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Porcelain v1 -z parser
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def parse_porcelain_v1z(data: bytes) -> list[ChangedFile]:
123
+ """Parse ``git status --porcelain=v1 -z`` output.
124
+
125
+ Each entry is ``XY<space><path>\\x00``. R/C (rename / copy) entries take
126
+ two NUL-separated fields: ``XY<space><new>\\x00<old>\\x00``.
127
+ """
128
+ if not data:
129
+ return []
130
+
131
+ tokens = data.split(b"\x00")
132
+ results: list[ChangedFile] = []
133
+ i = 0
134
+ while i < len(tokens):
135
+ tok = tokens[i]
136
+ if not tok:
137
+ i += 1
138
+ continue
139
+ if len(tok) < 3:
140
+ # Malformed entry; skip defensively rather than crash.
141
+ i += 1
142
+ continue
143
+ x = chr(tok[0])
144
+ y = chr(tok[1])
145
+ # tok[2] is the separator (typically a space). Path bytes start at 3.
146
+ path = tok[3:].decode("utf-8", errors="replace")
147
+ rename_from: str | None = None
148
+ if x in ("R", "C") or y in ("R", "C"):
149
+ i += 1
150
+ if i < len(tokens):
151
+ rename_from = tokens[i].decode("utf-8", errors="replace")
152
+ status = _classify_status(x, y)
153
+ results.append(
154
+ ChangedFile(path=path, status=status, rename_from=rename_from)
155
+ )
156
+ i += 1
157
+ return results
158
+
159
+
160
+ def _classify_status(x: str, y: str) -> ChangeStatus:
161
+ xy = x + y
162
+ if xy == "??":
163
+ return "untracked"
164
+ if x == "U" or y == "U" or xy in ("AA", "DD"):
165
+ return "unmerged"
166
+ if x in ("R", "C") or y in ("R", "C"):
167
+ return "renamed"
168
+ if x != " " and x not in ("?", "!"):
169
+ if x == "D":
170
+ return "staged_deleted"
171
+ return "staged"
172
+ if y == "M":
173
+ return "unstaged_modified"
174
+ if y == "D":
175
+ return "unstaged_deleted"
176
+ # Defensive fallback.
177
+ return "unstaged_modified"
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Low-level git helpers
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _git(
185
+ cwd: Path,
186
+ *args: str,
187
+ check: bool = True,
188
+ ) -> subprocess.CompletedProcess[bytes]:
189
+ return subprocess.run(
190
+ ["git", *args],
191
+ cwd=cwd,
192
+ capture_output=True,
193
+ check=check,
194
+ )
195
+
196
+
197
+ def _git_text(cwd: Path, *args: str, check: bool = True) -> str:
198
+ res = _git(cwd, *args, check=check)
199
+ return res.stdout.decode("utf-8", errors="replace").rstrip("\n")
200
+
201
+
202
+ def _safe_head(cwd: Path) -> str | None:
203
+ """Return HEAD SHA, or None if HEAD does not resolve (empty repo)."""
204
+ res = _git(cwd, "rev-parse", "HEAD", check=False)
205
+ if res.returncode != 0:
206
+ return None
207
+ return res.stdout.decode("utf-8", errors="replace").strip() or None
agentcam/models.py ADDED
@@ -0,0 +1,146 @@
1
+ """Data structures used across agentcam modules.
2
+
3
+ Plain dataclasses (no Pydantic) to keep dependencies to the standard library
4
+ only. JSON serialization is done by ``report.py`` and the manifest writer, not
5
+ by these classes.
6
+
7
+ See ``docs/design.md`` (forthcoming) for the schema design rationale.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Literal
14
+
15
+ # Two-level risk taxonomy (v0.1). LOW was dropped — see design.md decision 8.
16
+ RiskLevel = Literal["HIGH", "MEDIUM"]
17
+
18
+ ChangeStatus = Literal[
19
+ "staged",
20
+ "staged_deleted",
21
+ "unstaged_modified",
22
+ "unstaged_deleted",
23
+ "untracked",
24
+ "renamed",
25
+ "unmerged",
26
+ ]
27
+
28
+ # Source of the exit interpretation in manifest.exit_detail.
29
+ InterpretationSource = Literal[
30
+ "known_table",
31
+ "signal",
32
+ "user_defined",
33
+ "unknown",
34
+ ]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class RunId:
39
+ """Identifier for an agentcam run.
40
+
41
+ Format: ``YYYYMMDD-HHMMSS-<ms>-<slug>[-<hex>]``
42
+ where ``<hex>`` is a 4-char collision-avoidance suffix added on retry.
43
+ """
44
+
45
+ text: str
46
+
47
+ def __str__(self) -> str:
48
+ return self.text
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class RunPaths:
53
+ """Filesystem layout for a single agentcam run.
54
+
55
+ All paths live under ``<git_dir>/agentcam/runs/<run_id>/``. ``git_dir`` is
56
+ the *real* git dir as resolved by ``git rev-parse --git-dir`` (handles
57
+ worktrees and submodule gitlinks correctly).
58
+ """
59
+
60
+ run_dir: str
61
+ manifest_json: str
62
+ report_md: str
63
+ stdout_raw: str
64
+ stderr_raw: str
65
+ stdout_redacted: str
66
+ stderr_redacted: str
67
+
68
+
69
+ @dataclass
70
+ class ChangedFile:
71
+ """A file modified between pre-run and post-run git state."""
72
+
73
+ path: str
74
+ status: ChangeStatus
75
+ rename_from: str | None = None
76
+ secret_like_name: bool = False # True if filename matches secret-like pattern
77
+
78
+
79
+ @dataclass
80
+ class RiskFlag:
81
+ """A single risk observation. evidence must not contain raw secrets."""
82
+
83
+ level: RiskLevel
84
+ rule: str
85
+ evidence: str
86
+
87
+
88
+ @dataclass
89
+ class GitState:
90
+ """Snapshot of git state (before or after the wrapped command)."""
91
+
92
+ head: str | None
93
+ branch: str | None
94
+ is_detached_head: bool
95
+ porcelain_raw: bytes
96
+ diff_stat: str
97
+ diff_stat_cached: str
98
+ diff_name_status: str
99
+ diff_name_status_cached: str
100
+ diff_check: str = ""
101
+ diff_check_cached: str = ""
102
+ pre_existing_op: str | None = None # 'merge' | 'rebase' | 'cherry-pick' | ...
103
+ changed_files: list[ChangedFile] = field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class ExitDetail:
108
+ """Exit status detail, written to manifest and Exit Code Detail section.
109
+
110
+ See plan section 9 (Exit code pass-through).
111
+ """
112
+
113
+ wrapper_exit: int # 0 or 1
114
+ raw_returncode: int
115
+ raw_returncode_hex: str | None
116
+ platform: str
117
+ interpretation: str
118
+ interpretation_source: InterpretationSource
119
+
120
+
121
+ @dataclass
122
+ class RunManifest:
123
+ """Top-level run manifest, serialized to ``manifest.json``."""
124
+
125
+ schema_version: str
126
+ run_id: str
127
+ started_at: datetime
128
+ ended_at: datetime | None
129
+ duration_seconds: float | None
130
+ cwd: str
131
+ git_root: str
132
+ git_dir: str
133
+ branch: str | None
134
+ is_detached_head: bool
135
+ head_before: str | None
136
+ head_after: str | None
137
+ pre_existing_op: str | None
138
+ pre_run_dirty: bool
139
+ command_argv_raw: list[str]
140
+ command_argv_redacted: list[str]
141
+ exit_detail: ExitDetail | None
142
+ shell_used: bool
143
+ terminal_forward_degraded: bool
144
+ platform: str
145
+ agentcam_version: str
146
+ paths: RunPaths