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,137 @@
1
+ """Parse one Claude Code JSONL file from a byte offset, return new offset."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from ...schema.types import RawSessionHeader
10
+ from ..base import AdapterIngestResult, ParseError
11
+ from .extract_tool_calls import extract_tool_calls
12
+ from .extract_transcript import extract_transcript_segments
13
+ from .extract_turns import extract_turns
14
+ from .schemas import AssistantLine, UserLine
15
+
16
+ # Per-tick read cap to avoid OOM on a corrupt multi-GB JSONL.
17
+ MAX_TICK_BYTES = 64 * 1024 * 1024 # 64 MiB
18
+
19
+
20
+ def _empty_result(file_path: Path) -> AdapterIngestResult:
21
+ return AdapterIngestResult(
22
+ header=RawSessionHeader(
23
+ native_session_id=file_path.stem,
24
+ agent="claude_code",
25
+ agent_version=None,
26
+ project_path="",
27
+ started_at="",
28
+ ended_at=None,
29
+ agent_reported_cost_usd=None,
30
+ metadata={},
31
+ ),
32
+ turns=[],
33
+ tool_calls=[],
34
+ segments=[],
35
+ parse_errors=[],
36
+ )
37
+
38
+
39
+ def ingest_claude_code_file(
40
+ file_path: Path, from_offset: int = 0
41
+ ) -> tuple[AdapterIngestResult, int]:
42
+ """Read new bytes after ``from_offset``, parse, return (result, new_offset).
43
+
44
+ Partial trailing lines are held back until the next call. Malformed
45
+ lines are recorded as ParseErrors and skipped — surrounding lines
46
+ still ingest successfully.
47
+ """
48
+ with open(file_path, "rb") as fh:
49
+ fh.seek(0, 2) # end
50
+ size = fh.tell()
51
+ if size <= from_offset:
52
+ return _empty_result(file_path), from_offset
53
+ read_len = min(size - from_offset, MAX_TICK_BYTES)
54
+ fh.seek(from_offset)
55
+ raw = fh.read(read_len)
56
+
57
+ text = raw.decode("utf-8", errors="replace")
58
+ last_nl = text.rfind("\n")
59
+ consumable = text[: last_nl + 1] if last_nl != -1 else ""
60
+ consumed_bytes = len(consumable.encode("utf-8"))
61
+ new_offset = from_offset + consumed_bytes
62
+
63
+ assistant_lines: list[AssistantLine] = []
64
+ user_lines: list[UserLine] = []
65
+ parse_errors: list[ParseError] = []
66
+ line_offset = from_offset
67
+
68
+ for line in consumable.split("\n"):
69
+ if not line:
70
+ line_offset += 1
71
+ continue
72
+ line_bytes = len(line.encode("utf-8"))
73
+ try:
74
+ obj = json.loads(line)
75
+ except json.JSONDecodeError as e:
76
+ parse_errors.append(
77
+ ParseError(
78
+ file=str(file_path),
79
+ byte_offset=line_offset,
80
+ reason=str(e),
81
+ raw_line_truncated=line[:200],
82
+ )
83
+ )
84
+ line_offset += line_bytes + 1
85
+ continue
86
+
87
+ otype = obj.get("type") if isinstance(obj, dict) else None
88
+ try:
89
+ if otype == "assistant":
90
+ assistant_lines.append(AssistantLine.model_validate(obj))
91
+ elif otype == "user":
92
+ # User-line schema failure is non-fatal (loose schema) — skip
93
+ # without recording, otherwise parse_errors would flood.
94
+ try:
95
+ user_lines.append(UserLine.model_validate(obj))
96
+ except ValidationError:
97
+ pass
98
+ except ValidationError as e:
99
+ parse_errors.append(
100
+ ParseError(
101
+ file=str(file_path),
102
+ byte_offset=line_offset,
103
+ reason=str(e),
104
+ raw_line_truncated=line[:200],
105
+ )
106
+ )
107
+ line_offset += line_bytes + 1
108
+
109
+ turns = extract_turns(assistant_lines)
110
+ tool_calls = extract_tool_calls(assistant_lines, user_lines)
111
+ segments = extract_transcript_segments(assistant_lines, user_lines)
112
+
113
+ session_id = file_path.stem
114
+ cwd = assistant_lines[0].cwd if assistant_lines else ""
115
+ version = assistant_lines[-1].version if assistant_lines else None
116
+ started_at = (
117
+ assistant_lines[0].timestamp if assistant_lines else "1970-01-01T00:00:00.000Z"
118
+ )
119
+ ended_at = assistant_lines[-1].timestamp if assistant_lines else None
120
+
121
+ result = AdapterIngestResult(
122
+ header=RawSessionHeader(
123
+ native_session_id=session_id,
124
+ agent="claude_code",
125
+ agent_version=version,
126
+ project_path=cwd,
127
+ started_at=started_at,
128
+ ended_at=ended_at,
129
+ agent_reported_cost_usd=None,
130
+ metadata={},
131
+ ),
132
+ turns=turns,
133
+ tool_calls=tool_calls,
134
+ segments=segments,
135
+ parse_errors=parse_errors,
136
+ )
137
+ return result, new_offset
@@ -0,0 +1,11 @@
1
+ """Claude Code model-name canonicalization."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ _DATED_SUFFIX_RE = re.compile(r"-(\d{8})$")
7
+
8
+
9
+ def canonicalize_claude_model(raw: str) -> str:
10
+ """Strip a YYYYMMDD date suffix if present; leave aliases alone."""
11
+ return _DATED_SUFFIX_RE.sub("", raw)
@@ -0,0 +1,77 @@
1
+ """pydantic schemas for one line of a Claude Code JSONL session log.
2
+
3
+ Equivalents of the zod schemas in src/adapters/claude_code/schemas.ts.
4
+ ``model_config = ConfigDict(extra='allow')`` mirrors zod's ``.passthrough()``
5
+ so unknown fields like ``attribution_agent`` survive parsing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+
14
+ class _Passthrough(BaseModel):
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+
18
+ class CacheCreation(_Passthrough):
19
+ ephemeral_5m_input_tokens: int = 0
20
+ ephemeral_1h_input_tokens: int = 0
21
+
22
+
23
+ class Usage(_Passthrough):
24
+ input_tokens: int = Field(ge=0)
25
+ output_tokens: int = Field(ge=0)
26
+ cache_read_input_tokens: int = Field(default=0, ge=0)
27
+ cache_creation_input_tokens: int = Field(default=0, ge=0)
28
+ cache_creation: CacheCreation | None = None
29
+ service_tier: str | None = None
30
+
31
+
32
+ class AssistantMessage(_Passthrough):
33
+ id: str
34
+ model: str
35
+ role: Literal["assistant"]
36
+ content: list[dict[str, Any]] = Field(default_factory=list)
37
+ stop_reason: str | None = None
38
+ usage: Usage
39
+
40
+
41
+ class UserMessage(_Passthrough):
42
+ role: Literal["user"]
43
+ # Either a plain string ("hello") or a list of content blocks
44
+ # ([{type: 'tool_result', ...}, ...]).
45
+ content: str | list[Any]
46
+
47
+
48
+ class AssistantLine(_Passthrough):
49
+ type: Literal["assistant"]
50
+ sessionId: str
51
+ uuid: str
52
+ timestamp: str
53
+ cwd: str
54
+ version: str | None = None
55
+ userType: str | None = None
56
+ entrypoint: str | None = None
57
+ gitBranch: str | None = None
58
+ isSidechain: bool | None = None
59
+ agentId: str | None = None
60
+ requestId: str | None = None
61
+ attribution_agent: str | None = None
62
+ message: AssistantMessage
63
+
64
+
65
+ class UserLine(_Passthrough):
66
+ type: Literal["user"]
67
+ sessionId: str
68
+ uuid: str
69
+ timestamp: str
70
+ cwd: str
71
+ version: str | None = None
72
+ userType: str | None = None
73
+ entrypoint: str | None = None
74
+ gitBranch: str | None = None
75
+ isSidechain: bool | None = None
76
+ agentId: str | None = None
77
+ message: UserMessage
@@ -0,0 +1,30 @@
1
+ """Adapter registry.
2
+
3
+ Each Adapter class self-registers at import time via ``@register``.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from .base import Adapter
8
+
9
+ _REGISTRY: dict[str, type[Adapter]] = {}
10
+
11
+
12
+ def register(cls: type[Adapter]) -> type[Adapter]:
13
+ """Class decorator — adds ``cls`` to the registry by ``cls.agent``."""
14
+ _REGISTRY[cls.agent] = cls
15
+ return cls
16
+
17
+
18
+ def available_adapters() -> list[Adapter]:
19
+ """Instantiate every registered adapter whose ``is_present()`` is True."""
20
+ out: list[Adapter] = []
21
+ for cls in _REGISTRY.values():
22
+ a = cls()
23
+ if a.is_present():
24
+ out.append(a)
25
+ return out
26
+
27
+
28
+ def registered_adapter_names() -> list[str]:
29
+ """Names of every adapter the registry knows about (present or not)."""
30
+ return list(_REGISTRY.keys())
argus/cli.py ADDED
@@ -0,0 +1,384 @@
1
+ """``argus`` CLI — typer subcommand tree."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import shutil
6
+ import sys
7
+ import webbrowser
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ import argus.detectors # noqa: F401 — triggers @register side-effects
14
+
15
+ from .adapters.registry import available_adapters
16
+ from .collector.first_run import run_first_pass_ingest
17
+ from .collector.scheduler import start_scheduler
18
+ from .collector.watcher import start_watcher
19
+ from .detectors.registry import available_detectors
20
+ from .pricing.load import load_pricing_table
21
+ from .pricing.refresh import diff_pricing, fetch_litellm_table
22
+ from .scaffold.scaffolder import scaffold_project
23
+ from .scaffold.snapshot import snapshot_candidates, snapshot_template
24
+ from .scaffold.storage import RESERVED_TEMPLATE_NAMES, list_templates, resolve_template
25
+ from .server.app import ServerOpts, build_app, serve_blocking
26
+ from .store.db import open_db
27
+ from .store.repository import Repository
28
+
29
+ logger = logging.getLogger("argus")
30
+ app = typer.Typer(help="Local-first dashboard for coding-agent costs", no_args_is_help=True)
31
+ pricing_app = typer.Typer(help="Pricing-table operations")
32
+ search_app = typer.Typer(help="Manage transcript-search indexing")
33
+ app.add_typer(pricing_app, name="pricing")
34
+ app.add_typer(search_app, name="search")
35
+ claude_app = typer.Typer(help="Scaffold and manage .claude/ project setups")
36
+ template_app = typer.Typer(help="Manage scaffolding templates")
37
+ claude_app.add_typer(template_app, name="template")
38
+ app.add_typer(claude_app, name="claude")
39
+
40
+
41
+ def _setup_logging(quiet: bool = False, verbose: bool = False) -> None:
42
+ level = logging.WARNING if quiet else (logging.DEBUG if verbose else logging.INFO)
43
+ logging.basicConfig(
44
+ level=level,
45
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
46
+ datefmt="%H:%M:%S",
47
+ )
48
+
49
+
50
+ def _dashboard_dir() -> Path:
51
+ """Path of the bundled dashboard-dist (wheel) or repo-root one (dev)."""
52
+ try:
53
+ traversable = resources.files("argus") / "dashboard-dist"
54
+ candidate = Path(str(traversable))
55
+ if candidate.exists():
56
+ return candidate
57
+ except (ModuleNotFoundError, FileNotFoundError):
58
+ pass
59
+ return Path(__file__).resolve().parents[2] / "dashboard-dist"
60
+
61
+
62
+ def _default_data_dir() -> Path:
63
+ return Path.home() / ".argus"
64
+
65
+
66
+ @app.command()
67
+ def start(
68
+ port: int = typer.Option(4242, "-p", "--port"),
69
+ host: str = typer.Option(
70
+ "127.0.0.1",
71
+ "--host",
72
+ help=(
73
+ "Bind host. Default 127.0.0.1 (loopback only). Pass 0.0.0.0 to "
74
+ "expose on LAN — see SECURITY.md."
75
+ ),
76
+ ),
77
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
78
+ quiet: bool = typer.Option(False, "--quiet"),
79
+ verbose: bool = typer.Option(False, "--verbose"),
80
+ ) -> None:
81
+ """Start the watcher, ingester, and dashboard server."""
82
+ _setup_logging(quiet=quiet, verbose=verbose)
83
+
84
+ db = open_db(data_dir / "argus.db")
85
+ repo = Repository(db)
86
+
87
+ adapters = available_adapters()
88
+ if not adapters:
89
+ typer.echo(
90
+ "No adapter data found. Argus expects ~/.claude/ (Claude Code) "
91
+ "to be present at minimum.",
92
+ err=True,
93
+ )
94
+ raise typer.Exit(code=1)
95
+
96
+ names = ", ".join(a.agent for a in adapters)
97
+ logger.info("Detected adapters: %s", names)
98
+
99
+ table = load_pricing_table()
100
+ logger.info("Argus: ingesting recent sessions...")
101
+ handle = run_first_pass_ingest(adapters, repo, table, recent_days=30)
102
+ handle.wait_foreground()
103
+ s = handle.status()
104
+ logger.info(
105
+ "Argus: foreground ingest complete (%d/%d files), starting watcher...",
106
+ s.processed,
107
+ s.total,
108
+ )
109
+
110
+ watcher = start_watcher(adapters, repo, table)
111
+
112
+ detectors = available_detectors()
113
+ logger.info("Loaded %d detectors: %s", len(detectors), [d.name for d in detectors])
114
+ scheduler = start_scheduler(detectors, repo)
115
+
116
+ dash_dir = _dashboard_dir()
117
+ server_app = build_app(
118
+ repo,
119
+ ServerOpts(
120
+ pricing_table_version=table.version,
121
+ ingest_status=handle.status,
122
+ dashboard_dir=dash_dir,
123
+ port=port,
124
+ host=host,
125
+ adapters=adapters,
126
+ pricing_table=table,
127
+ ),
128
+ )
129
+ display_host = "localhost" if host in ("0.0.0.0", "::") else host
130
+ url = f"http://{display_host}:{port}"
131
+ logger.info("Argus running at %s", url)
132
+ if host in ("0.0.0.0", "::"):
133
+ logger.warning(
134
+ "⚠ WARNING: bound to all network interfaces. Anyone on your LAN "
135
+ "can read your prompt history, transcripts, and project paths."
136
+ )
137
+ try:
138
+ webbrowser.open(url)
139
+ except Exception: # noqa: BLE001
140
+ pass
141
+
142
+ try:
143
+ serve_blocking(server_app, host=host, port=port)
144
+ except KeyboardInterrupt:
145
+ pass
146
+ finally:
147
+ scheduler.stop()
148
+ watcher.stop()
149
+ db.close()
150
+ logger.info("Argus stopped.")
151
+
152
+
153
+ @pricing_app.command("refresh")
154
+ def pricing_refresh() -> None:
155
+ """Fetch the latest LiteLLM pricing and (after confirm) overwrite the bundled table."""
156
+ _setup_logging()
157
+ current = load_pricing_table()
158
+ typer.echo("Fetching latest pricing from LiteLLM...")
159
+ fresh = fetch_litellm_table()
160
+ diff = diff_pricing(current, fresh)
161
+ typer.echo(
162
+ f"Added: {len(diff.added)}, Removed: {len(diff.removed)}, "
163
+ f"Changed: {len(diff.changed)}, Unchanged: {len(diff.unchanged)}"
164
+ )
165
+ for k in diff.changed:
166
+ typer.echo(f" ~ {k}: {current.models[k].model_dump()} -> {fresh.models[k].model_dump()}")
167
+ for k in diff.added:
168
+ typer.echo(f" + {k}: {fresh.models[k].model_dump()}")
169
+ for k in diff.removed:
170
+ typer.echo(f" - {k}")
171
+ if not (diff.added or diff.changed or diff.removed):
172
+ typer.echo("Nothing to update.")
173
+ return
174
+ if not typer.confirm("Apply?"):
175
+ typer.echo("Cancelled.")
176
+ return
177
+ # Write into the repo's pricing/ dir if we're in dev; otherwise into the
178
+ # wheel's bundled dir (which is read-only for installed packages — print
179
+ # a hint).
180
+ try:
181
+ traversable = resources.files("argus") / "pricing"
182
+ out_dir = Path(str(traversable))
183
+ if not out_dir.is_dir():
184
+ raise FileNotFoundError(out_dir)
185
+ except (ModuleNotFoundError, FileNotFoundError):
186
+ out_dir = Path(__file__).resolve().parents[2] / "pricing"
187
+ out_dir.mkdir(parents=True, exist_ok=True)
188
+ out_file = out_dir / f"{fresh.version}.json"
189
+ out_file.write_text(fresh.model_dump_json(indent=2), encoding="utf-8")
190
+ typer.echo(f"Wrote {out_file}")
191
+
192
+
193
+ @search_app.command("status")
194
+ def search_status(data_dir: Path = typer.Option(_default_data_dir(), "--data-dir")) -> None:
195
+ db = open_db(data_dir / "argus.db")
196
+ repo = Repository(db)
197
+ enabled = repo.is_search_indexing_enabled()
198
+ segs = repo.segment_stats()
199
+ typer.echo(f"Status: {'ENABLED' if enabled else 'disabled'}")
200
+ typer.echo(f"Indexed: {segs['total']:,} segments across {segs['sessions']} sessions")
201
+ if not enabled and segs["total"] > 0:
202
+ typer.echo("Note: existing segments stay on disk but are hidden from search")
203
+ typer.echo(" until you re-enable. Use 'argus search clear' to wipe them.")
204
+
205
+
206
+ @search_app.command("enable")
207
+ def search_enable(data_dir: Path = typer.Option(_default_data_dir(), "--data-dir")) -> None:
208
+ db = open_db(data_dir / "argus.db")
209
+ repo = Repository(db)
210
+ if repo.is_search_indexing_enabled():
211
+ typer.echo("Already enabled.")
212
+ segs = repo.segment_stats()
213
+ typer.echo(f"Indexed: {segs['total']:,} segments across {segs['sessions']} sessions")
214
+ return
215
+ repo.set_search_indexing_enabled(True)
216
+ typer.echo("Enabled. Run `argus start` to backfill — historical sessions will")
217
+ typer.echo("index in the background. New sessions are indexed automatically.")
218
+
219
+
220
+ @search_app.command("disable")
221
+ def search_disable(data_dir: Path = typer.Option(_default_data_dir(), "--data-dir")) -> None:
222
+ db = open_db(data_dir / "argus.db")
223
+ repo = Repository(db)
224
+ repo.set_search_indexing_enabled(False)
225
+ segs = repo.segment_stats()
226
+ typer.echo("Disabled.")
227
+ if segs["total"] > 0:
228
+ typer.echo(f"{segs['total']:,} existing segments are still on disk but hidden")
229
+ typer.echo("from search. Run `argus search clear` to wipe them.")
230
+
231
+
232
+ @search_app.command("clear")
233
+ def search_clear(
234
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
235
+ yes: bool = typer.Option(False, "-y", "--yes"),
236
+ ) -> None:
237
+ db = open_db(data_dir / "argus.db")
238
+ repo = Repository(db)
239
+ segs = repo.segment_stats()
240
+ if segs["total"] == 0:
241
+ typer.echo("Nothing to clear (0 indexed segments).")
242
+ repo.set_search_indexing_enabled(False)
243
+ return
244
+ if not yes and not typer.confirm(f"Delete {segs['total']:,} indexed segments?"):
245
+ typer.echo("Cancelled.")
246
+ return
247
+ before_bytes = repo.db_size_bytes()
248
+ repo.clear_all_segments()
249
+ repo.set_search_indexing_enabled(False)
250
+ repo.vacuum()
251
+ after_bytes = repo.db_size_bytes()
252
+ freed = max(0, before_bytes - after_bytes)
253
+
254
+ def fmt(n: int) -> str:
255
+ if n >= 1024 * 1024:
256
+ return f"{n / 1024 / 1024:.1f} MB"
257
+ if n >= 1024:
258
+ return f"{n / 1024:.1f} KB"
259
+ return f"{n} B"
260
+
261
+ typer.echo(f"Deleted {segs['total']:,} segments.")
262
+ typer.echo(f"Freed {fmt(freed)} on disk (DB now {fmt(after_bytes)}).")
263
+
264
+
265
+ def _render_tree(paths: list[Path], root: Path) -> str:
266
+ rels = sorted(p.relative_to(root).as_posix() for p in paths)
267
+ seen: set[str] = set()
268
+ lines: list[str] = []
269
+ for r in rels:
270
+ parts = r.split("/")
271
+ for depth, part in enumerate(parts):
272
+ prefix = "/".join(parts[: depth + 1])
273
+ if prefix in seen:
274
+ continue
275
+ seen.add(prefix)
276
+ is_dir = depth < len(parts) - 1
277
+ lines.append(" " * depth + part + ("/" if is_dir else ""))
278
+ return "\n".join(lines)
279
+
280
+
281
+ @claude_app.command("init")
282
+ def claude_init(
283
+ path: Path = typer.Argument(Path("."), help="Target project directory"),
284
+ template: str = typer.Option("default", "--template", help="Template name"),
285
+ force: bool = typer.Option(
286
+ False, "--force", help="Overwrite existing files (never CLAUDE.md)"
287
+ ),
288
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
289
+ ) -> None:
290
+ """Scaffold a .claude/ setup (and CLAUDE.md) into a project."""
291
+ try:
292
+ template_dir = resolve_template(template, data_dir)
293
+ except KeyError:
294
+ avail = ", ".join(list_templates(data_dir)) or "(none)"
295
+ typer.echo(f"Unknown template '{template}'. Available: {avail}", err=True)
296
+ raise typer.Exit(code=1)
297
+
298
+ dest = path.resolve()
299
+ dest.mkdir(parents=True, exist_ok=True)
300
+ result = scaffold_project(template_dir, dest, force=force)
301
+
302
+ for p in result.created:
303
+ typer.echo(f" + {p.relative_to(dest).as_posix()}")
304
+ for p, reason in result.skipped:
305
+ typer.echo(f" - {p.relative_to(dest).as_posix()} ({reason})")
306
+
307
+ touched = result.created + [p for p, _ in result.skipped]
308
+ if touched:
309
+ typer.echo("\nResulting layout:")
310
+ typer.echo(_render_tree(touched, dest))
311
+ typer.echo(f"\nScaffolded '{template}' into {dest}")
312
+ typer.echo(f"{len(result.created)} created, {len(result.skipped)} skipped.")
313
+
314
+
315
+ @template_app.command("list")
316
+ def template_list(
317
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
318
+ ) -> None:
319
+ """List available template names (user templates shadow bundled)."""
320
+ names = list_templates(data_dir)
321
+ if not names:
322
+ typer.echo("No templates found.")
323
+ return
324
+ for n in names:
325
+ typer.echo(n)
326
+
327
+
328
+ @template_app.command("create")
329
+ def template_create(
330
+ name: str = typer.Argument(..., help="Template name to create"),
331
+ path: Path = typer.Option(Path("."), "--path", help="Project dir to snapshot"),
332
+ all_subdirs: bool = typer.Option(
333
+ False, "--all", help="Include every subfolder without prompting"
334
+ ),
335
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
336
+ ) -> None:
337
+ """Snapshot the current project's .claude/ into a reusable user template."""
338
+ if name in RESERVED_TEMPLATE_NAMES:
339
+ typer.echo(f"'{name}' is reserved, pick another name", err=True)
340
+ raise typer.Exit(code=1)
341
+
342
+ project = path.resolve()
343
+ if not (project / ".claude").is_dir():
344
+ typer.echo(f"No .claude/ directory found in {project}", err=True)
345
+ raise typer.Exit(code=1)
346
+
347
+ candidates = snapshot_candidates(project)
348
+ if all_subdirs:
349
+ included = candidates
350
+ else:
351
+ included = [
352
+ d for d in candidates if typer.confirm(f"Include {d}/?", default=True)
353
+ ]
354
+
355
+ try:
356
+ target = snapshot_template(
357
+ project, name, data_dir, include_subdirs=included
358
+ )
359
+ except ValueError as e:
360
+ typer.echo(str(e), err=True)
361
+ raise typer.Exit(code=1)
362
+ typer.echo(f"Created template '{name}' at {target}")
363
+
364
+
365
+ @app.command()
366
+ def wipe(
367
+ data_dir: Path = typer.Option(_default_data_dir(), "--data-dir"),
368
+ yes: bool = typer.Option(False, "-y", "--yes"),
369
+ ) -> None:
370
+ """Delete all local Argus data."""
371
+ if not yes and not typer.confirm(f"Delete {data_dir}?"):
372
+ typer.echo("Cancelled.")
373
+ return
374
+ if data_dir.exists():
375
+ shutil.rmtree(data_dir)
376
+ typer.echo(f"Deleted {data_dir}")
377
+
378
+
379
+ def main() -> None:
380
+ app()
381
+
382
+
383
+ if __name__ == "__main__":
384
+ main()
File without changes