assertion-cli 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.
main.py ADDED
@@ -0,0 +1,499 @@
1
+ import base64
2
+ import importlib.resources
3
+ import json
4
+ import re
5
+ import sys
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from api import AssertionClient
13
+ from bundle import build_bundle
14
+ from git import (
15
+ exit_with_error,
16
+ find_git_root,
17
+ get_head_branch,
18
+ get_head_sha,
19
+ get_uncommitted_diff,
20
+ )
21
+ from link import load_link, save_link
22
+ from models import CheckpointResponse, SessionContext, SessionStatus, render_stack_list
23
+ from session import (
24
+ ASSERTION_DIR_NAME,
25
+ METADATA_FILE_NAME,
26
+ PROMPTS_FILE_NAME,
27
+ append_checkpoint_entry,
28
+ continue_session,
29
+ load_existing_session,
30
+ start_new_session,
31
+ update_metadata_head,
32
+ )
33
+
34
+ app = typer.Typer(help="Assertion CLI")
35
+
36
+ STATUS_POLL_INTERVAL_SECONDS = 2.0
37
+
38
+
39
+ def render_checkpoint_response(resp: CheckpointResponse) -> str:
40
+ lines = [
41
+ f"checkpoint_id: {resp.checkpoint_id}",
42
+ f"outcome: {resp.outcome}",
43
+ ]
44
+ for review in resp.reviews:
45
+ lines.append("")
46
+ lines.append(f"model: {review.model}")
47
+ lines.append(f"approved: {str(review.approved).lower()}")
48
+ lines.append(review.feedback)
49
+
50
+ lines.append("")
51
+ lines.append(resp.reasoning)
52
+ return "\n".join(lines)
53
+
54
+
55
+ @app.callback(invoke_without_command=True)
56
+ def main(ctx: typer.Context) -> None:
57
+ """Assertion command group."""
58
+ if ctx.invoked_subcommand is None:
59
+ typer.echo(ctx.get_help())
60
+ raise typer.Exit(code=0)
61
+
62
+
63
+ @app.command("stacks")
64
+ def stacks() -> None:
65
+ """List available verification stacks."""
66
+ client = AssertionClient()
67
+ stack_list = client.stacks()
68
+ typer.echo(render_stack_list(stack_list))
69
+
70
+
71
+ ASSERTION_MARKER_BEGIN = "<!-- assertion-cli:begin -->"
72
+ ASSERTION_MARKER_END = "<!-- assertion-cli:end -->"
73
+
74
+
75
+ def _load_template(name: str) -> str:
76
+ return (
77
+ importlib.resources.files("assertion_cli_templates")
78
+ .joinpath(name)
79
+ .read_text(encoding="utf-8")
80
+ )
81
+
82
+
83
+ def _write_full_file(path: Path, content: str, repo_root: Path) -> None:
84
+ """Idempotently overwrite a file. Creates parent dirs as needed.
85
+
86
+ Silent on no-op; echoes only when a write actually happens.
87
+ """
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ if path.exists() and path.read_text(encoding="utf-8") == content:
90
+ return
91
+ path.write_text(content, encoding="utf-8")
92
+ typer.echo(f"Wrote {path.relative_to(repo_root)}")
93
+
94
+
95
+ def _apply_marked_block(path: Path, body: str, repo_root: Path) -> None:
96
+ """Idempotently insert/update an assertion-marked block in a markdown file.
97
+
98
+ Existing content outside the markers is preserved exactly.
99
+ Silent on no-op; echoes only when a write actually happens.
100
+ """
101
+ marked_block = f"{ASSERTION_MARKER_BEGIN}\n{body}\n{ASSERTION_MARKER_END}\n"
102
+ pattern = re.compile(
103
+ re.escape(ASSERTION_MARKER_BEGIN)
104
+ + r".*?"
105
+ + re.escape(ASSERTION_MARKER_END)
106
+ + r"\n?",
107
+ re.DOTALL,
108
+ )
109
+
110
+ if path.exists():
111
+ current = path.read_text(encoding="utf-8")
112
+ if pattern.search(current):
113
+ new_content = pattern.sub(marked_block, current)
114
+ else:
115
+ sep = (
116
+ ""
117
+ if current.endswith("\n\n")
118
+ else ("\n" if current.endswith("\n") else "\n\n")
119
+ )
120
+ new_content = current + sep + marked_block
121
+ if new_content != current:
122
+ path.write_text(new_content, encoding="utf-8")
123
+ typer.echo(f"Updated {path.relative_to(repo_root)}")
124
+ else:
125
+ path.parent.mkdir(parents=True, exist_ok=True)
126
+ path.write_text(marked_block, encoding="utf-8")
127
+ typer.echo(f"Wrote {path.relative_to(repo_root)}")
128
+
129
+
130
+ def _refresh_skill_files(repo_root: Path) -> None:
131
+ """Write/refresh the assertion-owned skill files for each coding agent.
132
+
133
+ Safe to call on every checkpoint — these files are entirely ours; updating
134
+ them to match the installed CLI version cannot clobber customer content.
135
+
136
+ `.claude/skills/` — Claude Code
137
+ `.agents/skills/` — Codex + Cursor (Cursor also accepts .cursor/skills/)
138
+ """
139
+ skill_body = _load_template("SKILL.md")
140
+ _write_full_file(
141
+ repo_root / ".claude" / "skills" / "assertion-cli" / "SKILL.md",
142
+ skill_body,
143
+ repo_root,
144
+ )
145
+ _write_full_file(
146
+ repo_root / ".agents" / "skills" / "assertion-cli" / "SKILL.md",
147
+ skill_body,
148
+ repo_root,
149
+ )
150
+
151
+
152
+ _GITIGNORE_ASSERTION_ENTRY = ".assertion/"
153
+
154
+
155
+ def _ensure_gitignore_excludes_assertion(repo_root: Path) -> None:
156
+ """Append `.assertion/` to the repo's .gitignore if not already excluded.
157
+
158
+ `.assertion/` holds per-session state (prompts, checkpoint message,
159
+ metadata, screenshots) — purely local working data. Without this entry it
160
+ surfaces as untracked changes that `asrt checkpoint`'s diff collector
161
+ would otherwise bundle and send to the reviewers.
162
+ """
163
+ gitignore = repo_root / ".gitignore"
164
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
165
+ if any(
166
+ line.strip() in {".assertion", ".assertion/"} for line in existing.splitlines()
167
+ ):
168
+ return
169
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
170
+ gitignore.write_text(
171
+ existing + prefix + _GITIGNORE_ASSERTION_ENTRY + "\n", encoding="utf-8"
172
+ )
173
+ typer.echo(
174
+ f"Updated {gitignore.relative_to(repo_root)} (added {_GITIGNORE_ASSERTION_ENTRY})"
175
+ )
176
+
177
+
178
+ def _apply_activation_blocks(repo_root: Path) -> None:
179
+ """Insert/update the marked activation block in customer-owned CLAUDE.md / AGENTS.md.
180
+
181
+ Only called by `asrt init`. We deliberately do NOT call this on every checkpoint —
182
+ these files may be tracked in the customer's git, and silently dirtying them on
183
+ every checkpoint is hostile. The block is a stable pointer to the skill file, so
184
+ re-running on every CLI upgrade isn't necessary.
185
+ """
186
+ activation_body = _load_template("ACTIVATION.md").strip()
187
+ _apply_marked_block(repo_root / "CLAUDE.md", activation_body, repo_root)
188
+ _apply_marked_block(repo_root / "AGENTS.md", activation_body, repo_root)
189
+
190
+
191
+ def _clear_session_state(ctx: SessionContext) -> None:
192
+ """Remove per-session files so the next prompt/checkpoint starts cleanly.
193
+
194
+ Leaves the `.assertion/` directory itself (and `link`, `screenshots/`) in
195
+ place so the customer can still retrieve the just-completed session's URL.
196
+ """
197
+ for path in (ctx.prompts_path, ctx.checkpoint_path, ctx.metadata_path):
198
+ path.unlink(missing_ok=True)
199
+
200
+
201
+ @app.command("init")
202
+ def init() -> None:
203
+ """One-time bootstrap: install the assertion-cli skill files into this repo.
204
+
205
+ Writes `.claude/skills/assertion-cli/SKILL.md`, `.agents/skills/assertion-cli/SKILL.md`,
206
+ and the marked activation block in `CLAUDE.md` and `AGENTS.md`. Run this once after
207
+ `uv tool install assertion-cli` so the coding agent (Claude Code, Codex, Cursor) loads
208
+ the skill and knows to call `asrt`. `asrt checkpoint` refreshes the skill files on each
209
+ run so they stay aligned with the installed CLI; `CLAUDE.md` / `AGENTS.md` are only
210
+ touched here, never by checkpoint, so they're safe to track in git.
211
+ """
212
+ repo_root = find_git_root(Path.cwd())
213
+ _refresh_skill_files(repo_root)
214
+ _apply_activation_blocks(repo_root)
215
+ _ensure_gitignore_excludes_assertion(repo_root)
216
+ typer.echo("")
217
+ typer.echo("The coding agent will now follow the Assertion workflow:")
218
+ typer.echo(' - asrt prompt "<msg>" on every customer turn')
219
+ typer.echo(" - asrt checkpoint at trajectory-feedback moments")
220
+ typer.echo(" - asrt verify at completion, then PR")
221
+
222
+
223
+ @app.command("prompt")
224
+ def prompt_cmd(
225
+ text: str = typer.Argument(..., help="Customer prompt text to record."),
226
+ ) -> None:
227
+ """Append a customer prompt to the session prompt history."""
228
+ normalized = text.strip()
229
+ if not normalized:
230
+ exit_with_error("Prompt text must not be empty.")
231
+
232
+ repo_root = find_git_root(Path.cwd())
233
+ assertion_dir = repo_root / ASSERTION_DIR_NAME
234
+ assertion_dir.mkdir(exist_ok=True)
235
+ prompts_path = assertion_dir / PROMPTS_FILE_NAME
236
+
237
+ entry = {
238
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
239
+ "text": normalized,
240
+ }
241
+ with prompts_path.open("a", encoding="utf-8") as f:
242
+ f.write(json.dumps(entry) + "\n")
243
+
244
+
245
+ @app.command("checkpoint")
246
+ def checkpoint(
247
+ message: str = typer.Argument(..., help="Checkpoint progress message."),
248
+ stack: str | None = typer.Option(
249
+ None,
250
+ "--stack",
251
+ help="Force a new session against this stack ID. Overwrites any existing session.",
252
+ ),
253
+ continue_session_flag: bool = typer.Option(
254
+ False,
255
+ "--continue",
256
+ help="Require an existing session to continue (fail if none exists).",
257
+ ),
258
+ json_output: bool = typer.Option(
259
+ False, "--json", help="Print the checkpoint response as a single JSON line."
260
+ ),
261
+ ) -> None:
262
+ """Record a checkpoint.
263
+
264
+ With no flag, the CLI auto-continues the existing session if one is present,
265
+ otherwise starts a new session against the stack attached to this repo's
266
+ GitHub origin. Use `--continue` to fail fast when no session exists, or
267
+ `--stack <id>` to force a brand-new session (destructive — replaces any
268
+ in-flight session).
269
+ """
270
+ if stack and continue_session_flag:
271
+ typer.echo("ERROR: --stack and --continue are mutually exclusive.", err=True)
272
+ raise typer.Exit(code=1)
273
+
274
+ repo_root = find_git_root(Path.cwd())
275
+
276
+ # Refresh the assertion-owned skill files so the agent always loads the
277
+ # version that ships with the installed CLI. We deliberately do NOT touch
278
+ # CLAUDE.md / AGENTS.md here — those are customer-owned and only updated
279
+ # by `asrt init`.
280
+ _refresh_skill_files(repo_root)
281
+
282
+ existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
283
+
284
+ if continue_session_flag or (stack is None and existing_metadata_path.exists()):
285
+ # Either the caller asked to continue explicitly, OR they passed no
286
+ # flag and a session already exists (2nd+ checkpoint of the same
287
+ # session). In both cases continue rather than starting fresh — the
288
+ # alternative would silently destroy an in-flight session. To force a
289
+ # new session, pass --stack <id> or clear .assertion/ first (the
290
+ # post-verify prompt offers to do this).
291
+ ctx, metadata = continue_session(start_path=Path.cwd())
292
+ else:
293
+ from session import resolve_stack_id_for_repo
294
+
295
+ resolved_stack = stack or resolve_stack_id_for_repo(repo_root)
296
+ ctx, metadata = start_new_session(
297
+ start_path=Path.cwd(), stack_id=resolved_stack
298
+ )
299
+
300
+ stack_id = metadata.stack_id
301
+ assert stack_id is not None
302
+
303
+ append_checkpoint_entry(ctx.checkpoint_path, message)
304
+ head_sha = get_head_sha(ctx.repo_root)
305
+ head_branch = get_head_branch(ctx.repo_root)
306
+ diff_text = get_uncommitted_diff(ctx.repo_root)
307
+ metadata = update_metadata_head(
308
+ ctx.metadata_path, metadata, head_sha, head_branch=head_branch
309
+ )
310
+
311
+ bundle_bytes = build_bundle(
312
+ metadata=metadata,
313
+ diff_text=diff_text,
314
+ prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
315
+ checkpoint_text=message,
316
+ )
317
+
318
+ client = AssertionClient()
319
+ resp = client.checkpoint(
320
+ stack_id=stack_id,
321
+ session_id=metadata.session_id,
322
+ bundle_bytes=bundle_bytes,
323
+ )
324
+ if json_output:
325
+ typer.echo(resp.model_dump_json())
326
+ else:
327
+ typer.echo(render_checkpoint_response(resp))
328
+ if str(resp.outcome).endswith("failed"):
329
+ raise typer.Exit(code=1)
330
+
331
+
332
+ @app.command("verify")
333
+ def verify(
334
+ json_output: bool = typer.Option(
335
+ False,
336
+ "--json",
337
+ help="Print the terminal verification status as a single JSON line.",
338
+ ),
339
+ no_wait: bool = typer.Option(
340
+ False,
341
+ "--no-wait",
342
+ help="Submit and return immediately without polling. Useful for harnesses.",
343
+ ),
344
+ ) -> None:
345
+ """Submit final verification and wait for the result."""
346
+ ctx, metadata = load_existing_session(Path.cwd())
347
+ stack_id = metadata.stack_id
348
+ assert stack_id is not None
349
+ head_sha = get_head_sha(ctx.repo_root)
350
+ head_branch = get_head_branch(ctx.repo_root)
351
+ diff_text = get_uncommitted_diff(ctx.repo_root)
352
+ metadata = update_metadata_head(
353
+ ctx.metadata_path, metadata, head_sha, head_branch=head_branch
354
+ )
355
+
356
+ bundle_bytes = build_bundle(
357
+ metadata=metadata,
358
+ diff_text=diff_text,
359
+ prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
360
+ checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
361
+ )
362
+
363
+ client = AssertionClient()
364
+ resp = client.verify(
365
+ stack_id=stack_id,
366
+ session_id=metadata.session_id,
367
+ bundle_bytes=bundle_bytes,
368
+ )
369
+
370
+ session_id = resp.session_id
371
+ session_url = resp.url
372
+ save_link(ctx.assertion_dir, session_url)
373
+
374
+ if no_wait:
375
+ if json_output:
376
+ typer.echo(
377
+ json.dumps(
378
+ {
379
+ "session_id": session_id,
380
+ "url": session_url,
381
+ "status": "submitted",
382
+ }
383
+ )
384
+ )
385
+ else:
386
+ typer.echo(f"Verification submitted ({session_id}). URL: {session_url}")
387
+ return
388
+
389
+ if not json_output:
390
+ typer.echo(f"Verification submitted ({session_id}). Waiting for result...")
391
+
392
+ poll_count = 0
393
+ while True:
394
+ payload = client.status(session_id=session_id)
395
+
396
+ if payload.status in {SessionStatus.CREATED, SessionStatus.RUNNING}:
397
+ poll_count += 1
398
+ if not json_output:
399
+ sys.stderr.write(".")
400
+ sys.stderr.flush()
401
+ time.sleep(STATUS_POLL_INTERVAL_SECONDS)
402
+ continue
403
+
404
+ if poll_count > 0 and not json_output:
405
+ sys.stderr.write("\n")
406
+ sys.stderr.flush()
407
+
408
+ if payload.url:
409
+ session_url = payload.url
410
+ save_link(ctx.assertion_dir, session_url)
411
+
412
+ if payload.screenshots:
413
+ screenshot_dir = Path(".assertion") / "screenshots" / session_id
414
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
415
+ for idx, uri in enumerate(payload.screenshots):
416
+ _, b64data = uri.split(",", 1)
417
+ ext = "jpeg" if "jpeg" in uri.split(",")[0] else "png"
418
+ path = screenshot_dir / f"screenshot_{idx:03d}.{ext}"
419
+ path.write_bytes(base64.b64decode(b64data))
420
+
421
+ if json_output:
422
+ typer.echo(
423
+ json.dumps(
424
+ {
425
+ "session_id": session_id,
426
+ "url": session_url,
427
+ "status": str(payload.status),
428
+ "message": payload.message,
429
+ "summary": payload.summary,
430
+ "screenshot_count": len(payload.screenshots),
431
+ }
432
+ )
433
+ )
434
+ if payload.status == SessionStatus.FAILED:
435
+ raise typer.Exit(code=1)
436
+ return
437
+
438
+ if payload.message is None:
439
+ typer.echo(
440
+ "Invalid API response: terminal status must include a message.",
441
+ err=True,
442
+ )
443
+ raise typer.Exit(code=1)
444
+
445
+ typer.echo(payload.message)
446
+ if payload.summary:
447
+ typer.echo(f"\nSummary:\n{payload.summary}")
448
+ if payload.screenshots:
449
+ typer.echo(
450
+ f"\nScreenshots ({len(payload.screenshots)}) saved to "
451
+ f".assertion/screenshots/{session_id}/"
452
+ )
453
+ typer.echo(f"\nSession: {session_url}")
454
+ if payload.status == SessionStatus.FAILED:
455
+ raise typer.Exit(code=1)
456
+
457
+ # On a clean verify in an interactive shell, offer to clear session
458
+ # state so the next `asrt prompt` / `asrt checkpoint` starts fresh.
459
+ if sys.stdin.isatty() and typer.confirm(
460
+ "\nStart a new session? This clears .assertion/ (prompts, metadata, checkpoint).",
461
+ default=False,
462
+ ):
463
+ _clear_session_state(ctx)
464
+ typer.echo("Cleared. Next `asrt prompt` starts a new session.")
465
+ return
466
+
467
+
468
+ @app.command("get-link")
469
+ def get_link() -> None:
470
+ """Print the session link for the current verification session."""
471
+ ctx, metadata = load_existing_session(Path.cwd())
472
+ url = load_link(ctx.assertion_dir)
473
+ typer.echo(url)
474
+
475
+
476
+ @app.command("decision")
477
+ def decision(
478
+ checkpoint_id: str = typer.Argument(..., help="Checkpoint ID."),
479
+ yes: bool = typer.Option(
480
+ False, "--yes", help="Agree with the checkpoint feedback."
481
+ ),
482
+ no: bool = typer.Option(
483
+ False, "--no", help="Disagree with the checkpoint feedback."
484
+ ),
485
+ ) -> None:
486
+ """Record an agree/disagree sentiment for a failed checkpoint."""
487
+ if yes == no:
488
+ typer.echo("ERROR: Pass exactly one of --yes or --no.", err=True)
489
+ raise typer.Exit(code=1)
490
+
491
+ client = AssertionClient()
492
+ client.decision(
493
+ checkpoint_id=checkpoint_id,
494
+ decision="yes" if yes else "no",
495
+ )
496
+
497
+
498
+ if __name__ == "__main__":
499
+ app()
models.py ADDED
@@ -0,0 +1,86 @@
1
+ from dataclasses import dataclass
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+ from typing import Sequence
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class VerificationStack(BaseModel):
10
+ id: str
11
+ name: str
12
+ description: str
13
+ repo: str
14
+
15
+
16
+ class InitResponse(BaseModel):
17
+ session_id: str
18
+ stacks: list[VerificationStack]
19
+
20
+
21
+ class VerifyResponse(BaseModel):
22
+ session_id: str
23
+ url: str
24
+
25
+
26
+ class ReviewDetail(BaseModel):
27
+ model: str
28
+ approved: bool
29
+ feedback: str
30
+
31
+
32
+ class CheckpointResponse(BaseModel):
33
+ checkpoint_id: str
34
+ outcome: str
35
+ reasoning: str
36
+ reviews: list[ReviewDetail]
37
+ created_at: str
38
+ updated_at: str
39
+
40
+
41
+ class DecisionResponse(BaseModel):
42
+ ok: bool = True
43
+
44
+
45
+ class SessionStatus(StrEnum):
46
+ CREATED = "created"
47
+ RUNNING = "running"
48
+ SUCCEEDED = "succeeded"
49
+ FAILED = "failed"
50
+
51
+
52
+ class StatusResponse(BaseModel):
53
+ status: SessionStatus
54
+ message: str | None = None
55
+ summary: str | None = None
56
+ screenshots: list[str] = []
57
+ url: str | None = None
58
+
59
+
60
+ class ErrorResponse(BaseModel):
61
+ error: str
62
+
63
+
64
+ class MetadataPayload(BaseModel):
65
+ model_config = ConfigDict(extra="allow")
66
+
67
+ session_id: str
68
+ stack_id: str | None = None
69
+ head_sha: str | None = None
70
+ head_branch: str | None = None
71
+
72
+
73
+ def render_stack_list(stacks: Sequence["VerificationStack"]) -> str:
74
+ lines: list[str] = []
75
+ for s in stacks:
76
+ lines.append(f"- {s.id}: {s.name} [repo: {s.repo}] — {s.description}")
77
+ return "\n".join(lines)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SessionContext:
82
+ repo_root: Path
83
+ assertion_dir: Path
84
+ prompts_path: Path
85
+ checkpoint_path: Path
86
+ metadata_path: Path