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.
- argus/__init__.py +3 -0
- argus/adapters/__init__.py +7 -0
- argus/adapters/base.py +108 -0
- argus/adapters/claude_code/__init__.py +5 -0
- argus/adapters/claude_code/adapter.py +63 -0
- argus/adapters/claude_code/discover.py +72 -0
- argus/adapters/claude_code/extract_tool_calls.py +86 -0
- argus/adapters/claude_code/extract_transcript.py +111 -0
- argus/adapters/claude_code/extract_turns.py +69 -0
- argus/adapters/claude_code/history_jsonl.py +138 -0
- argus/adapters/claude_code/ingest_file.py +137 -0
- argus/adapters/claude_code/model.py +11 -0
- argus/adapters/claude_code/schemas.py +77 -0
- argus/adapters/registry.py +30 -0
- argus/cli.py +384 -0
- argus/collector/__init__.py +0 -0
- argus/collector/aggregate.py +102 -0
- argus/collector/first_run.py +189 -0
- argus/collector/pipeline.py +140 -0
- argus/collector/rollup_subagents.py +27 -0
- argus/collector/scheduler.py +89 -0
- argus/collector/search_backfill.py +109 -0
- argus/collector/watcher.py +178 -0
- argus/dashboard-dist/_astro/charts.BIevw6Es.js +1 -0
- argus/dashboard-dist/_astro/format.DxC1NGYT.js +1 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.CgwSARdD.js +24 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.W18SJsr7.js +11 -0
- argus/dashboard-dist/_astro/installCanvasRenderer.D_tC6TXz.js +18 -0
- argus/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.BHTHXYHC.js +13 -0
- argus/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.DfNgiDv9.js +17 -0
- argus/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.Dj_bfrIa.js +86 -0
- argus/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.d_a-uvdi.js +24 -0
- argus/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.Dzzau3Yt.js +12 -0
- argus/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.BLLeGRNa.js +5 -0
- argus/dashboard-dist/index.html +2 -0
- argus/dashboard-dist/models/index.html +1 -0
- argus/dashboard-dist/prompts/index.html +18 -0
- argus/dashboard-dist/session/index.html +2 -0
- argus/dashboard-dist/sessions/index.html +1 -0
- argus/dashboard-dist/settings/index.html +8 -0
- argus/dashboard-dist/styles/global.css +307 -0
- argus/dashboard-dist/tools/index.html +1 -0
- argus/dashboard-dist/trends/index.html +1 -0
- argus/detectors/__init__.py +6 -0
- argus/detectors/base.py +34 -0
- argus/detectors/registry.py +20 -0
- argus/detectors/tool_error_rate_spike.py +138 -0
- argus/pricing/2026-05-02.json +24 -0
- argus/pricing/__init__.py +0 -0
- argus/pricing/compute.py +46 -0
- argus/pricing/load.py +45 -0
- argus/pricing/refresh.py +91 -0
- argus/pricing/types.py +21 -0
- argus/scaffold/__init__.py +0 -0
- argus/scaffold/scaffolder.py +45 -0
- argus/scaffold/snapshot.py +73 -0
- argus/scaffold/storage.py +60 -0
- argus/schema/__init__.py +0 -0
- argus/schema/types.py +157 -0
- argus/server/__init__.py +0 -0
- argus/server/api.py +661 -0
- argus/server/app.py +97 -0
- argus/store/__init__.py +0 -0
- argus/store/db.py +103 -0
- argus/store/migrations/__init__.py +0 -0
- argus/store/migrations/inline.py +180 -0
- argus/store/repository.py +778 -0
- argus/templates/default/.claude/agents/code-reviewer.md +27 -0
- argus/templates/default/.claude/agents/security-auditor.md +28 -0
- argus/templates/default/.claude/commands/commit.md +38 -0
- argus/templates/default/.claude/commands/deploy.md +13 -0
- argus/templates/default/.claude/commands/fix-issue.md +15 -0
- argus/templates/default/.claude/commands/pr.md +38 -0
- argus/templates/default/.claude/commands/review.md +14 -0
- argus/templates/default/.claude/rules/api-conventions.md +27 -0
- argus/templates/default/.claude/rules/code-style.md +25 -0
- argus/templates/default/.claude/rules/testing.md +19 -0
- argus/templates/default/.claude/settings.json +28 -0
- argus/templates/default/.claude/skills/example/SKILL.md +11 -0
- argus/templates/default/CLAUDE.md +57 -0
- argus_code-0.2.0.dist-info/METADATA +247 -0
- argus_code-0.2.0.dist-info/RECORD +86 -0
- argus_code-0.2.0.dist-info/WHEEL +4 -0
- argus_code-0.2.0.dist-info/entry_points.txt +2 -0
- argus_code-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|