argus-code 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. argus/__init__.py +3 -0
  2. argus/adapters/__init__.py +7 -0
  3. argus/adapters/base.py +108 -0
  4. argus/adapters/claude_code/__init__.py +5 -0
  5. argus/adapters/claude_code/adapter.py +63 -0
  6. argus/adapters/claude_code/discover.py +72 -0
  7. argus/adapters/claude_code/extract_tool_calls.py +86 -0
  8. argus/adapters/claude_code/extract_transcript.py +111 -0
  9. argus/adapters/claude_code/extract_turns.py +69 -0
  10. argus/adapters/claude_code/history_jsonl.py +138 -0
  11. argus/adapters/claude_code/ingest_file.py +137 -0
  12. argus/adapters/claude_code/model.py +11 -0
  13. argus/adapters/claude_code/schemas.py +77 -0
  14. argus/adapters/registry.py +30 -0
  15. argus/cli.py +384 -0
  16. argus/collector/__init__.py +0 -0
  17. argus/collector/aggregate.py +102 -0
  18. argus/collector/first_run.py +189 -0
  19. argus/collector/pipeline.py +140 -0
  20. argus/collector/rollup_subagents.py +27 -0
  21. argus/collector/scheduler.py +89 -0
  22. argus/collector/search_backfill.py +109 -0
  23. argus/collector/watcher.py +178 -0
  24. argus/dashboard-dist/_astro/charts.BIevw6Es.js +1 -0
  25. argus/dashboard-dist/_astro/format.DxC1NGYT.js +1 -0
  26. argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.CgwSARdD.js +24 -0
  27. argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.W18SJsr7.js +11 -0
  28. argus/dashboard-dist/_astro/installCanvasRenderer.D_tC6TXz.js +18 -0
  29. argus/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.BHTHXYHC.js +13 -0
  30. argus/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.DfNgiDv9.js +17 -0
  31. argus/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.Dj_bfrIa.js +86 -0
  32. argus/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.d_a-uvdi.js +24 -0
  33. argus/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.Dzzau3Yt.js +12 -0
  34. argus/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.BLLeGRNa.js +5 -0
  35. argus/dashboard-dist/index.html +2 -0
  36. argus/dashboard-dist/models/index.html +1 -0
  37. argus/dashboard-dist/prompts/index.html +18 -0
  38. argus/dashboard-dist/session/index.html +2 -0
  39. argus/dashboard-dist/sessions/index.html +1 -0
  40. argus/dashboard-dist/settings/index.html +8 -0
  41. argus/dashboard-dist/styles/global.css +307 -0
  42. argus/dashboard-dist/tools/index.html +1 -0
  43. argus/dashboard-dist/trends/index.html +1 -0
  44. argus/detectors/__init__.py +6 -0
  45. argus/detectors/base.py +34 -0
  46. argus/detectors/registry.py +20 -0
  47. argus/detectors/tool_error_rate_spike.py +138 -0
  48. argus/pricing/2026-05-02.json +24 -0
  49. argus/pricing/__init__.py +0 -0
  50. argus/pricing/compute.py +46 -0
  51. argus/pricing/load.py +45 -0
  52. argus/pricing/refresh.py +91 -0
  53. argus/pricing/types.py +21 -0
  54. argus/scaffold/__init__.py +0 -0
  55. argus/scaffold/scaffolder.py +45 -0
  56. argus/scaffold/snapshot.py +73 -0
  57. argus/scaffold/storage.py +60 -0
  58. argus/schema/__init__.py +0 -0
  59. argus/schema/types.py +157 -0
  60. argus/server/__init__.py +0 -0
  61. argus/server/api.py +661 -0
  62. argus/server/app.py +97 -0
  63. argus/store/__init__.py +0 -0
  64. argus/store/db.py +103 -0
  65. argus/store/migrations/__init__.py +0 -0
  66. argus/store/migrations/inline.py +180 -0
  67. argus/store/repository.py +778 -0
  68. argus/templates/default/.claude/agents/code-reviewer.md +27 -0
  69. argus/templates/default/.claude/agents/security-auditor.md +28 -0
  70. argus/templates/default/.claude/commands/commit.md +38 -0
  71. argus/templates/default/.claude/commands/deploy.md +13 -0
  72. argus/templates/default/.claude/commands/fix-issue.md +15 -0
  73. argus/templates/default/.claude/commands/pr.md +38 -0
  74. argus/templates/default/.claude/commands/review.md +14 -0
  75. argus/templates/default/.claude/rules/api-conventions.md +27 -0
  76. argus/templates/default/.claude/rules/code-style.md +25 -0
  77. argus/templates/default/.claude/rules/testing.md +19 -0
  78. argus/templates/default/.claude/settings.json +28 -0
  79. argus/templates/default/.claude/skills/example/SKILL.md +11 -0
  80. argus/templates/default/CLAUDE.md +57 -0
  81. argus_code-0.2.0.dist-info/METADATA +247 -0
  82. argus_code-0.2.0.dist-info/RECORD +86 -0
  83. argus_code-0.2.0.dist-info/WHEEL +4 -0
  84. argus_code-0.2.0.dist-info/entry_points.txt +2 -0
  85. argus_code-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. argus_code-0.2.0.dist-info/licenses/NOTICE +22 -0
@@ -0,0 +1,6 @@
1
+ """Detectors — one module per finding type.
2
+
3
+ Importing this module auto-imports every known detector so their
4
+ ``@register`` decorators run and the registry is populated.
5
+ """
6
+ from . import tool_error_rate_spike # noqa: F401
@@ -0,0 +1,34 @@
1
+ """Detector contract.
2
+
3
+ Detectors are pure: they read from a Repository and return findings.
4
+ Writes are performed by the scheduler, not the detector. This split keeps
5
+ detectors trivially unit-testable and the alerts table single-writer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
11
+
12
+ from ..schema.types import AlertSeverity
13
+
14
+ if TYPE_CHECKING:
15
+ from ..store.repository import Repository
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Finding:
20
+ """One alert-shaped result returned by a detector."""
21
+
22
+ detector: str
23
+ dedup_key: str
24
+ severity: AlertSeverity
25
+ title: str
26
+ message: str
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ @runtime_checkable
31
+ class Detector(Protocol):
32
+ name: str
33
+
34
+ def detect(self, repo: "Repository", now_iso: str) -> list[Finding]: ...
@@ -0,0 +1,20 @@
1
+ """Detector registry. Mirrors argus.adapters.registry verbatim."""
2
+ from __future__ import annotations
3
+
4
+ from .base import Detector
5
+
6
+ _REGISTRY: dict[str, type[Detector]] = {}
7
+
8
+
9
+ def register(cls):
10
+ """Class decorator — adds ``cls`` to the registry by ``cls.name``."""
11
+ _REGISTRY[cls.name] = cls
12
+ return cls
13
+
14
+
15
+ def available_detectors() -> list[Detector]:
16
+ return [cls() for cls in _REGISTRY.values()]
17
+
18
+
19
+ def registered_detector_names() -> list[str]:
20
+ return list(_REGISTRY.keys())
@@ -0,0 +1,138 @@
1
+ """tool_error_rate_spike detector.
2
+
3
+ For each tool, compares the trailing-7-day error rate (`window`) against
4
+ the 28 days immediately before that (`baseline`). Fires a finding when:
5
+
6
+ - window_rate >= 2 * baseline_rate
7
+ - window_calls >= 20
8
+ - baseline_calls >= 20
9
+
10
+ Severity ``warning`` for ratios in [2, 5), ``critical`` for >= 5. The
11
+ 20-call floor in each window suppresses the worst of the low-volume
12
+ noise; deliberate decision to *not* add an absolute-rate floor on the
13
+ ratio path for v1.
14
+
15
+ Special case: a tool that had zero errors across the baseline but starts
16
+ failing in the window (>= 5%) fires ``critical`` with a distinct title.
17
+ That's the most informative behavior change and the pure-ratio rule can't
18
+ express it (division by zero).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timedelta, timezone
23
+ from typing import TYPE_CHECKING
24
+
25
+ from .base import Finding
26
+ from .registry import register
27
+
28
+ if TYPE_CHECKING:
29
+ from ..store.repository import Repository
30
+
31
+
32
+ _WINDOW_DAYS = 7
33
+ _BASELINE_DAYS = 28
34
+ _MIN_CALLS = 20
35
+ _WARNING_MULTIPLE = 2.0
36
+ _CRITICAL_MULTIPLE = 5.0
37
+ _ZERO_BASELINE_MIN_WINDOW_RATE = 0.05 # 5%
38
+
39
+
40
+ def _iso_at_offset(now_iso: str, days_ago: float) -> str:
41
+ base = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
42
+ if base.tzinfo is None:
43
+ base = base.replace(tzinfo=timezone.utc)
44
+ return (base - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z")
45
+
46
+
47
+ @register
48
+ class ToolErrorRateSpikeDetector:
49
+ name = "tool_error_rate_spike"
50
+
51
+ def detect(self, repo: "Repository", now_iso: str) -> list[Finding]:
52
+ window_start = _iso_at_offset(now_iso, _WINDOW_DAYS)
53
+ baseline_start = _iso_at_offset(now_iso, _WINDOW_DAYS + _BASELINE_DAYS)
54
+
55
+ window_rows = repo.tool_call_stats_in_range(
56
+ start_iso=window_start, end_iso=now_iso
57
+ )
58
+ baseline_rows = repo.tool_call_stats_in_range(
59
+ start_iso=baseline_start, end_iso=window_start
60
+ )
61
+ baseline_by_tool = {r["tool_name"]: r for r in baseline_rows}
62
+
63
+ findings: list[Finding] = []
64
+ for w in window_rows:
65
+ tool = w["tool_name"]
66
+ window_calls = int(w["calls"])
67
+ window_errors = int(w["errors"])
68
+ if window_calls < _MIN_CALLS:
69
+ continue
70
+
71
+ b = baseline_by_tool.get(tool)
72
+ if b is None:
73
+ continue
74
+ baseline_calls = int(b["calls"])
75
+ baseline_errors = int(b["errors"])
76
+ if baseline_calls < _MIN_CALLS:
77
+ continue
78
+
79
+ window_rate = window_errors / window_calls
80
+ baseline_rate = baseline_errors / baseline_calls
81
+
82
+ if baseline_rate == 0:
83
+ # "Broke from zero" — a previously-clean tool starting to
84
+ # fail. Fire critical when window_rate clears the noise
85
+ # floor; the call-count gates above already guarantee
86
+ # statistical relevance.
87
+ if window_rate < _ZERO_BASELINE_MIN_WINDOW_RATE:
88
+ continue
89
+ findings.append(
90
+ Finding(
91
+ detector=self.name,
92
+ dedup_key=tool,
93
+ severity="critical",
94
+ title=f"{tool} started failing this week (no prior errors)",
95
+ message=(
96
+ f"Last 7d: {window_rate * 100:.1f}% over {window_calls} calls. "
97
+ f"Prior 28d: 0 errors over {baseline_calls} calls."
98
+ ),
99
+ metadata={
100
+ "tool_name": tool,
101
+ "window_rate": window_rate,
102
+ "baseline_rate": 0.0,
103
+ "window_calls": window_calls,
104
+ "baseline_calls": baseline_calls,
105
+ },
106
+ )
107
+ )
108
+ continue
109
+
110
+ multiple = window_rate / baseline_rate
111
+ if multiple < _WARNING_MULTIPLE:
112
+ continue
113
+
114
+ severity = "critical" if multiple >= _CRITICAL_MULTIPLE else "warning"
115
+ findings.append(
116
+ Finding(
117
+ detector=self.name,
118
+ dedup_key=tool,
119
+ severity=severity,
120
+ title=(
121
+ f"{tool} error rate jumped to {window_rate * 100:.1f}% "
122
+ f"this week (baseline {baseline_rate * 100:.1f}%)"
123
+ ),
124
+ message=(
125
+ f"Last 7d: {window_rate * 100:.1f}% over {window_calls} calls. "
126
+ f"Prior 28d: {baseline_rate * 100:.1f}% over {baseline_calls} calls."
127
+ ),
128
+ metadata={
129
+ "tool_name": tool,
130
+ "window_rate": window_rate,
131
+ "baseline_rate": baseline_rate,
132
+ "window_calls": window_calls,
133
+ "baseline_calls": baseline_calls,
134
+ "multiple": multiple,
135
+ },
136
+ )
137
+ )
138
+ return findings
@@ -0,0 +1,24 @@
1
+ {
2
+ "version": "2026-05-02",
3
+ "models": {
4
+ "claude-opus-4-7": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
5
+ "claude-opus-4-6": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
6
+ "claude-opus-4-5": { "input": 5, "output": 25, "cache_write_5m": 6.25, "cache_write_1h": 10, "cache_read": 0.50 },
7
+ "claude-opus-4-1": { "input": 15, "output": 75, "cache_write_5m": 18.75,"cache_write_1h": 30, "cache_read": 1.50 },
8
+ "claude-opus-4": { "input": 15, "output": 75, "cache_write_5m": 18.75,"cache_write_1h": 30, "cache_read": 1.50 },
9
+ "claude-sonnet-4-6": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
10
+ "claude-sonnet-4-5": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
11
+ "claude-sonnet-4": { "input": 3, "output": 15, "cache_write_5m": 3.75, "cache_write_1h": 6, "cache_read": 0.30 },
12
+ "claude-haiku-4-5": { "input": 1, "output": 5, "cache_write_5m": 1.25, "cache_write_1h": 2, "cache_read": 0.10 },
13
+ "claude-haiku-3-5": { "input": 0.80, "output": 4, "cache_write_5m": 1, "cache_write_1h": 1.60, "cache_read": 0.08 },
14
+ "claude-haiku-3": { "input": 0.25, "output": 1.25,"cache_write_5m": 0.30, "cache_write_1h": 0.50, "cache_read": 0.03 },
15
+ "gpt-5.5": { "input": 5, "output": 30, "cache_read": 0.50 },
16
+ "gpt-5.4": { "input": 2.50, "output": 15, "cache_read": 0.25 },
17
+ "gpt-5.4-mini": { "input": 0.75, "output": 4.50, "cache_read": 0.075 },
18
+ "gpt-5.4-nano": { "input": 0.20, "output": 1.25, "cache_read": 0.02 },
19
+ "gpt-5.3-codex": { "input": 1.75, "output": 14, "cache_read": 0.175 },
20
+ "gpt-5.3-chat-latest": { "input": 1.75, "output": 14, "cache_read": 0.175 },
21
+ "gpt-5.2": { "input": 1.75, "output": 14, "cache_read": 0.175 },
22
+ "gpt-5.1": { "input": 1.25, "output": 10, "cache_read": 0.125 }
23
+ }
24
+ }
File without changes
@@ -0,0 +1,46 @@
1
+ """Per-turn cost computation."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from .types import PricingTable
7
+
8
+ PER_MTOK = 1_000_000
9
+
10
+
11
+ def compute_turn_cost(t: Any, table: PricingTable) -> float:
12
+ """Compute the USD cost for one turn given a pricing table.
13
+
14
+ ``t`` is duck-typed — it must expose ``model``, ``fresh_input_tokens``,
15
+ ``output_tokens``, ``cache_read_tokens``, ``cache_write_tokens``,
16
+ ``cache_write_5m_tokens``, ``cache_write_1h_tokens``. Both
17
+ pydantic models (RawTurnEvent, Turn) and plain dicts work via
18
+ ``getattr`` / dict access.
19
+ """
20
+ def g(name: str, default: Any = None) -> Any:
21
+ if isinstance(t, dict):
22
+ return t.get(name, default)
23
+ return getattr(t, name, default)
24
+
25
+ model = g("model")
26
+ p = table.models.get(model)
27
+ if p is None:
28
+ return 0.0
29
+
30
+ cw5 = p.cache_write_5m if p.cache_write_5m is not None else p.input
31
+ cw1 = p.cache_write_1h if p.cache_write_1h is not None else p.input
32
+
33
+ cw5_tokens = g("cache_write_5m_tokens")
34
+ cw1_tokens = g("cache_write_1h_tokens")
35
+
36
+ if cw5_tokens is not None and cw1_tokens is not None:
37
+ write_tier = (cw5_tokens * cw5 + cw1_tokens * cw1) / PER_MTOK
38
+ else:
39
+ write_tier = (g("cache_write_tokens", 0) * cw5) / PER_MTOK
40
+
41
+ return (
42
+ (g("fresh_input_tokens", 0) * p.input) / PER_MTOK
43
+ + (g("output_tokens", 0) * p.output) / PER_MTOK
44
+ + (g("cache_read_tokens", 0) * p.cache_read) / PER_MTOK
45
+ + write_tier
46
+ )
argus/pricing/load.py ADDED
@@ -0,0 +1,45 @@
1
+ """Load the bundled pricing JSON shipped inside the wheel."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from importlib import resources
6
+ from pathlib import Path
7
+
8
+ from .types import PricingTable
9
+
10
+ _DEFAULT_FILENAME = "2026-05-02.json"
11
+
12
+
13
+ def _bundled_dir() -> Path:
14
+ """Path of the bundled ``pricing/`` directory.
15
+
16
+ Works in:
17
+ - installed wheel (data shipped alongside the package as argus/pricing/)
18
+ - editable install (`uv pip install -e .`)
19
+ - dev `uv run` from the repo (pricing/ at repo root)
20
+
21
+ The in-wheel path coincides with the source layout's ``argus/pricing``
22
+ subpackage (which has its own ``__init__.py``). We only trust that
23
+ location if the expected JSON actually lives there — otherwise we fall
24
+ back to the repo-root ``pricing/`` directory.
25
+ """
26
+ # In-wheel location: only trust it if the expected file is present.
27
+ try:
28
+ traversable = resources.files("argus") / "pricing"
29
+ candidate = Path(str(traversable))
30
+ if (candidate / _DEFAULT_FILENAME).exists():
31
+ return candidate
32
+ except (ModuleNotFoundError, FileNotFoundError):
33
+ pass
34
+
35
+ # Dev fallback: repo-root pricing/.
36
+ # python/argus/pricing/load.py → parents[3] is the repo root.
37
+ here = Path(__file__).resolve()
38
+ return here.parents[3] / "pricing"
39
+
40
+
41
+ def load_pricing_table(path: Path | None = None) -> PricingTable:
42
+ """Read a pricing JSON from disk and parse it."""
43
+ p = path or (_bundled_dir() / _DEFAULT_FILENAME)
44
+ raw = p.read_text(encoding="utf-8")
45
+ return PricingTable.model_validate(json.loads(raw))
@@ -0,0 +1,91 @@
1
+ """Diff against current pricing and fetch the latest from LiteLLM."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import json
6
+ from typing import Any, Callable
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from .types import ModelPricing, PricingTable
11
+
12
+ LITELLM_URL = (
13
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/"
14
+ "model_prices_and_context_window.json"
15
+ )
16
+
17
+
18
+ class PricingDiff(BaseModel):
19
+ added: list[str]
20
+ removed: list[str]
21
+ changed: list[str]
22
+ unchanged: list[str]
23
+
24
+
25
+ def diff_pricing(old: PricingTable, new: PricingTable) -> PricingDiff:
26
+ """Return added / removed / changed / unchanged model-key sets."""
27
+ old_keys = set(old.models.keys())
28
+ new_keys = set(new.models.keys())
29
+
30
+ added = sorted(new_keys - old_keys)
31
+ removed = sorted(old_keys - new_keys)
32
+ changed: list[str] = []
33
+ unchanged: list[str] = []
34
+ for k in new_keys & old_keys:
35
+ a = old.models[k].model_dump()
36
+ b = new.models[k].model_dump()
37
+ if json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True):
38
+ unchanged.append(k)
39
+ else:
40
+ changed.append(k)
41
+ return PricingDiff(
42
+ added=added,
43
+ removed=removed,
44
+ changed=sorted(changed),
45
+ unchanged=sorted(unchanged),
46
+ )
47
+
48
+
49
+ def fetch_litellm_table(
50
+ url: str = LITELLM_URL,
51
+ fetch: Callable[[str], dict[str, Any]] | None = None,
52
+ ) -> PricingTable:
53
+ """Fetch LiteLLM's per-token pricing JSON and convert to per-MTok shape.
54
+
55
+ ``fetch`` is the network function; tests inject a stub so no real
56
+ HTTPS is made. Default is httpx.
57
+ """
58
+ if fetch is None:
59
+ import httpx # local import — httpx is the runtime default
60
+
61
+ def _fetch(u: str) -> dict[str, Any]:
62
+ r = httpx.get(u, timeout=30.0)
63
+ r.raise_for_status()
64
+ return r.json()
65
+
66
+ fetch = _fetch
67
+
68
+ raw = fetch(url)
69
+ models: dict[str, ModelPricing] = {}
70
+ for k, v in raw.items():
71
+ if k == "sample_spec":
72
+ continue
73
+ if not isinstance(v, dict) or "input_cost_per_token" not in v:
74
+ continue
75
+ input_ = (v.get("input_cost_per_token") or 0) * 1_000_000
76
+ output = (v.get("output_cost_per_token") or 0) * 1_000_000
77
+ cache_read = (v.get("cache_read_input_token_cost") or 0) * 1_000_000
78
+ cache_write = (v.get("cache_creation_input_token_cost") or 0) * 1_000_000
79
+ entry: dict[str, Any] = {
80
+ "input": input_,
81
+ "output": output,
82
+ "cache_read": cache_read,
83
+ }
84
+ if cache_write:
85
+ entry["cache_write_5m"] = cache_write
86
+ entry["cache_write_1h"] = cache_write * 1.6
87
+ models[k] = ModelPricing.model_validate(entry)
88
+ return PricingTable(
89
+ version=dt.datetime.now(dt.timezone.utc).date().isoformat(),
90
+ models=models,
91
+ )
argus/pricing/types.py ADDED
@@ -0,0 +1,21 @@
1
+ """Pricing-table model — direct port of src/pricing/types.ts."""
2
+ from __future__ import annotations
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+
7
+ class ModelPricing(BaseModel):
8
+ model_config = ConfigDict(extra="allow")
9
+
10
+ input: float
11
+ output: float
12
+ cache_write_5m: float | None = None
13
+ cache_write_1h: float | None = None
14
+ cache_read: float
15
+
16
+
17
+ class PricingTable(BaseModel):
18
+ model_config = ConfigDict(extra="allow")
19
+
20
+ version: str
21
+ models: dict[str, ModelPricing] = Field(default_factory=dict)
File without changes
@@ -0,0 +1,45 @@
1
+ """Copy a template directory into a project — the `argus claude init` core."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ # CLAUDE.md is the user's project brain — never overwrite it, even with --force.
9
+ _CLAUDE_MD = "CLAUDE.md"
10
+
11
+
12
+ @dataclass
13
+ class ScaffoldResult:
14
+ created: list[Path] = field(default_factory=list)
15
+ skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
16
+
17
+
18
+ def scaffold_project(
19
+ template_dir: Path, dest: Path, *, force: bool = False
20
+ ) -> ScaffoldResult:
21
+ """Copy every file under ``template_dir`` into ``dest``, preserving layout.
22
+
23
+ - A template file at ``CLAUDE.md`` lands at ``dest/CLAUDE.md``; everything
24
+ else (all under ``.claude/``) lands at ``dest/.claude/...``.
25
+ - An existing destination file is skipped unless ``force`` is set.
26
+ - ``CLAUDE.md`` is NEVER overwritten, even with ``force``.
27
+ """
28
+ result = ScaffoldResult()
29
+ for src in sorted(p for p in template_dir.rglob("*") if p.is_file()):
30
+ rel = src.relative_to(template_dir)
31
+ target = dest / rel
32
+ is_claude_md = rel.as_posix() == _CLAUDE_MD
33
+
34
+ if target.exists():
35
+ if is_claude_md:
36
+ result.skipped.append((target, "CLAUDE.md exists, left untouched"))
37
+ continue
38
+ if not force:
39
+ result.skipped.append((target, "exists"))
40
+ continue
41
+
42
+ target.parent.mkdir(parents=True, exist_ok=True)
43
+ shutil.copyfile(src, target)
44
+ result.created.append(target)
45
+ return result
@@ -0,0 +1,73 @@
1
+ """Snapshot a project's .claude/ into a named user template (`template create`)."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from .storage import RESERVED_TEMPLATE_NAMES, user_templates_dir
8
+
9
+ # Never snapshot these — session/history/cache, or machine-local config.
10
+ _EXCLUDED_DIRS = {
11
+ "projects", "todos", "shell-snapshots", "statsig", "logs", "ide",
12
+ "__pycache__", ".DS_Store",
13
+ }
14
+ _EXCLUDED_TOP_FILE_SUFFIXES = (".local.json",)
15
+ _EXCLUDED_TOP_FILE_NAMES = {"history.jsonl"}
16
+ _COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
17
+
18
+
19
+ def snapshot_candidates(project_dir: Path) -> list[str]:
20
+ """Subfolder names under ``project_dir/.claude/`` eligible for snapshotting."""
21
+ claude = project_dir / ".claude"
22
+ if not claude.is_dir():
23
+ return []
24
+ return sorted(
25
+ p.name
26
+ for p in claude.iterdir()
27
+ if p.is_dir() and p.name not in _EXCLUDED_DIRS
28
+ )
29
+
30
+
31
+ def _safe_top_files(claude: Path) -> list[Path]:
32
+ out: list[Path] = []
33
+ for p in claude.iterdir():
34
+ if not p.is_file():
35
+ continue
36
+ if p.name in _EXCLUDED_TOP_FILE_NAMES:
37
+ continue
38
+ if any(p.name.endswith(s) for s in _EXCLUDED_TOP_FILE_SUFFIXES):
39
+ continue
40
+ out.append(p)
41
+ return out
42
+
43
+
44
+ def snapshot_template(
45
+ project_dir: Path, name: str, data_dir: Path, *, include_subdirs: list[str]
46
+ ) -> Path:
47
+ """Copy chosen ``.claude/`` subfolders + safe top-level files into a template.
48
+
49
+ Raises ``ValueError`` if ``name`` is reserved, the template already exists,
50
+ or there is no ``.claude/`` directory to snapshot. Returns the new template dir.
51
+ """
52
+ if name in RESERVED_TEMPLATE_NAMES:
53
+ raise ValueError(f"'{name}' is reserved, pick another name")
54
+ claude = project_dir / ".claude"
55
+ if not claude.is_dir():
56
+ raise ValueError(f"no .claude/ directory found in {project_dir}")
57
+
58
+ target_root = user_templates_dir(data_dir) / name
59
+ if target_root.exists():
60
+ raise ValueError(
61
+ f"template '{name}' already exists; delete it or choose another name"
62
+ )
63
+
64
+ dest_claude = target_root / ".claude"
65
+ dest_claude.mkdir(parents=True)
66
+
67
+ for f in _safe_top_files(claude):
68
+ shutil.copyfile(f, dest_claude / f.name)
69
+ for sub in include_subdirs:
70
+ src = claude / sub
71
+ if src.is_dir():
72
+ shutil.copytree(src, dest_claude / sub, ignore=_COPY_IGNORE)
73
+ return target_root
@@ -0,0 +1,60 @@
1
+ """Locate and resolve `argus claude` project-scaffolding templates.
2
+
3
+ Two tiers:
4
+ - bundled templates ship inside the wheel (read-only), mirroring ``pricing/``.
5
+ - user templates live under ``~/.argus/templates/<name>/`` (read-write).
6
+
7
+ ``resolve_template`` checks the user dir first, then bundled.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from importlib import resources
12
+ from pathlib import Path
13
+
14
+ RESERVED_TEMPLATE_NAMES = {"default"}
15
+
16
+
17
+ def bundled_templates_dir() -> Path:
18
+ """Path of the bundled ``templates/`` dir (in-wheel, else repo-root in dev)."""
19
+ try:
20
+ traversable = resources.files("argus") / "templates"
21
+ candidate = Path(str(traversable))
22
+ if (candidate / "default" / "CLAUDE.md").exists():
23
+ return candidate
24
+ except (ModuleNotFoundError, FileNotFoundError):
25
+ pass
26
+ # Dev fallback: repo-root templates/.
27
+ # python/argus/scaffold/storage.py -> parents[3] is the repo root.
28
+ return Path(__file__).resolve().parents[3] / "templates"
29
+
30
+
31
+ def user_templates_dir(data_dir: Path) -> Path:
32
+ """Path of the user template store (``~/.argus/templates`` by default)."""
33
+ return data_dir / "templates"
34
+
35
+
36
+ def _subdir_names(d: Path) -> list[str]:
37
+ if not d.is_dir():
38
+ return []
39
+ return sorted(p.name for p in d.iterdir() if p.is_dir())
40
+
41
+
42
+ def list_templates(data_dir: Path) -> list[str]:
43
+ """All available template names: bundled ∪ user, sorted and de-duped."""
44
+ names = set(_subdir_names(bundled_templates_dir()))
45
+ names.update(_subdir_names(user_templates_dir(data_dir)))
46
+ return sorted(names)
47
+
48
+
49
+ def resolve_template(name: str, data_dir: Path) -> Path:
50
+ """Return the directory for ``name``, preferring user templates.
51
+
52
+ Raises ``KeyError`` if no template with that name exists in either tier.
53
+ """
54
+ user = user_templates_dir(data_dir) / name
55
+ if user.is_dir():
56
+ return user
57
+ bundled = bundled_templates_dir() / name
58
+ if bundled.is_dir():
59
+ return bundled
60
+ raise KeyError(name)
File without changes