ctxfire 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.
- ctxfire/__init__.py +3 -0
- ctxfire/__main__.py +5 -0
- ctxfire/adapters.py +185 -0
- ctxfire/cli.py +297 -0
- ctxfire/config.py +149 -0
- ctxfire/discovery.py +101 -0
- ctxfire/model.py +72 -0
- ctxfire/py.typed +1 -0
- ctxfire/render.py +152 -0
- ctxfire/scanner.py +174 -0
- ctxfire-0.1.0.dist-info/METADATA +268 -0
- ctxfire-0.1.0.dist-info/RECORD +15 -0
- ctxfire-0.1.0.dist-info/WHEEL +4 -0
- ctxfire-0.1.0.dist-info/entry_points.txt +2 -0
- ctxfire-0.1.0.dist-info/licenses/LICENSE +21 -0
ctxfire/__init__.py
ADDED
ctxfire/__main__.py
ADDED
ctxfire/adapters.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Versioned loading-semantics adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
|
|
11
|
+
from .config import AgentConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Inclusion:
|
|
16
|
+
pattern: str
|
|
17
|
+
activation: str
|
|
18
|
+
reason: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=512)
|
|
22
|
+
def _glob_regex(pattern: str) -> re.Pattern[str]:
|
|
23
|
+
normalized = pattern.removeprefix("./")
|
|
24
|
+
index = 0
|
|
25
|
+
translated = ""
|
|
26
|
+
while index < len(normalized):
|
|
27
|
+
character = normalized[index]
|
|
28
|
+
if character == "*":
|
|
29
|
+
if index + 1 < len(normalized) and normalized[index + 1] == "*":
|
|
30
|
+
index += 2
|
|
31
|
+
if index < len(normalized) and normalized[index] == "/":
|
|
32
|
+
translated += "(?:.*/)?"
|
|
33
|
+
index += 1
|
|
34
|
+
else:
|
|
35
|
+
translated += ".*"
|
|
36
|
+
continue
|
|
37
|
+
translated += "[^/]*"
|
|
38
|
+
elif character == "?":
|
|
39
|
+
translated += "[^/]"
|
|
40
|
+
elif character == "[":
|
|
41
|
+
closing = normalized.find("]", index + 1)
|
|
42
|
+
if closing == -1:
|
|
43
|
+
translated += r"\["
|
|
44
|
+
else:
|
|
45
|
+
content = normalized[index + 1 : closing]
|
|
46
|
+
if content.startswith("!"):
|
|
47
|
+
content = "^" + content[1:]
|
|
48
|
+
translated += "[" + content.replace("\\", r"\\") + "]"
|
|
49
|
+
index = closing
|
|
50
|
+
else:
|
|
51
|
+
translated += re.escape(character)
|
|
52
|
+
index += 1
|
|
53
|
+
return re.compile(f"^{translated}$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def matches(path: str, pattern: str) -> bool:
|
|
57
|
+
"""Match a root-relative POSIX path with ``*`` and recursive ``**`` globs."""
|
|
58
|
+
|
|
59
|
+
return _glob_regex(pattern).fullmatch(path) is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _chain_directories(working_directory: str) -> tuple[PurePosixPath, ...]:
|
|
63
|
+
current = PurePosixPath() if working_directory == "." else PurePosixPath(working_directory)
|
|
64
|
+
return tuple(PurePosixPath(*current.parts[:depth]) for depth in range(len(current.parts) + 1))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _under(directory: PurePosixPath, relative: str) -> str:
|
|
68
|
+
return (directory / relative).as_posix()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def inclusions(
|
|
72
|
+
agent: AgentConfig, available: Mapping[str, int] | None = None
|
|
73
|
+
) -> tuple[Inclusion, ...]:
|
|
74
|
+
"""Expand an adapter to ordered, explainable include rules."""
|
|
75
|
+
|
|
76
|
+
available = available or {}
|
|
77
|
+
result: list[Inclusion] = []
|
|
78
|
+
directories = _chain_directories(agent.working_directory)
|
|
79
|
+
if agent.adapter == "agents-md@1":
|
|
80
|
+
result.extend(
|
|
81
|
+
Inclusion(_under(directory, "AGENTS.md"), "always", "agents-md@1 chain")
|
|
82
|
+
for directory in directories
|
|
83
|
+
)
|
|
84
|
+
if agent.adapter == "codex@1":
|
|
85
|
+
for directory in directories:
|
|
86
|
+
candidates = (
|
|
87
|
+
"AGENTS.override.md",
|
|
88
|
+
"AGENTS.md",
|
|
89
|
+
*agent.instruction_fallback_filenames,
|
|
90
|
+
)
|
|
91
|
+
for filename in candidates:
|
|
92
|
+
path = _under(directory, filename)
|
|
93
|
+
if available.get(path, 0) > 0:
|
|
94
|
+
result.append(Inclusion(path, "always", "codex@1 instruction chain precedence"))
|
|
95
|
+
break
|
|
96
|
+
result.append(
|
|
97
|
+
Inclusion(
|
|
98
|
+
_under(directory, ".agents/skills/*/SKILL.md"),
|
|
99
|
+
"conditional",
|
|
100
|
+
"codex@1 skill body; loaded only when selected",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if agent.adapter == "claude-code@1":
|
|
104
|
+
for directory in directories:
|
|
105
|
+
for filename in ("CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md"):
|
|
106
|
+
result.append(
|
|
107
|
+
Inclusion(
|
|
108
|
+
_under(directory, filename),
|
|
109
|
+
"always",
|
|
110
|
+
"claude-code@1 project memory ancestor chain",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
result.append(
|
|
114
|
+
Inclusion(
|
|
115
|
+
_under(directory, ".claude/rules/**/*.md"),
|
|
116
|
+
agent.claude_rules_activation,
|
|
117
|
+
"claude-code@1 project rule; metadata-only mode cannot parse paths frontmatter",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
result.append(
|
|
121
|
+
Inclusion(
|
|
122
|
+
_under(directory, ".claude/skills/*/SKILL.md"),
|
|
123
|
+
"conditional",
|
|
124
|
+
"claude-code@1 skill body; loaded only when selected",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
working_prefix = "" if agent.working_directory == "." else f"{agent.working_directory}/"
|
|
128
|
+
result.extend(
|
|
129
|
+
[
|
|
130
|
+
Inclusion(
|
|
131
|
+
f"{working_prefix}**/CLAUDE.md",
|
|
132
|
+
"conditional",
|
|
133
|
+
"claude-code@1 descendant memory; loaded when that subtree is read",
|
|
134
|
+
),
|
|
135
|
+
Inclusion(
|
|
136
|
+
f"{working_prefix}**/CLAUDE.local.md",
|
|
137
|
+
"conditional",
|
|
138
|
+
"claude-code@1 descendant local memory; loaded when that subtree is read",
|
|
139
|
+
),
|
|
140
|
+
Inclusion(
|
|
141
|
+
f"{working_prefix}**/.claude/skills/*/SKILL.md",
|
|
142
|
+
"conditional",
|
|
143
|
+
"claude-code@1 descendant skill body; available when that subtree is read",
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
result.extend(Inclusion(pattern, "always", "explicit include") for pattern in agent.include)
|
|
148
|
+
result.extend(
|
|
149
|
+
Inclusion(pattern, "conditional", "explicit conditional include")
|
|
150
|
+
for pattern in agent.conditional
|
|
151
|
+
)
|
|
152
|
+
return tuple(result)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def exact_probe_paths(agent: AgentConfig) -> tuple[str, ...]:
|
|
156
|
+
"""Return known exact paths that engines load even when Git ignores them."""
|
|
157
|
+
|
|
158
|
+
result: list[str] = []
|
|
159
|
+
directories = _chain_directories(agent.working_directory)
|
|
160
|
+
if agent.adapter == "agents-md@1":
|
|
161
|
+
result.extend(_under(directory, "AGENTS.md") for directory in directories)
|
|
162
|
+
elif agent.adapter == "codex@1":
|
|
163
|
+
for directory in directories:
|
|
164
|
+
result.extend(
|
|
165
|
+
_under(directory, filename)
|
|
166
|
+
for filename in (
|
|
167
|
+
"AGENTS.override.md",
|
|
168
|
+
"AGENTS.md",
|
|
169
|
+
*agent.instruction_fallback_filenames,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
elif agent.adapter == "claude-code@1":
|
|
173
|
+
for directory in directories:
|
|
174
|
+
result.extend(
|
|
175
|
+
_under(directory, filename)
|
|
176
|
+
for filename in ("CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md")
|
|
177
|
+
)
|
|
178
|
+
for pattern in (*agent.include, *agent.conditional):
|
|
179
|
+
if not any(character in pattern for character in "*?["):
|
|
180
|
+
result.append(pattern)
|
|
181
|
+
return tuple(dict.fromkeys(result))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def excluded(path: str, patterns: tuple[str, ...]) -> bool:
|
|
185
|
+
return any(matches(path, pattern) for pattern in patterns)
|
ctxfire/cli.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Command-line interface for ctxfire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .render import explain_text, json_text, sarif, scan_text
|
|
14
|
+
from .scanner import scan
|
|
15
|
+
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_ERROR = 1
|
|
18
|
+
EXIT_BUDGET_EXCEEDED = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _write(text: str, output: Path | None) -> None:
|
|
22
|
+
if output is None:
|
|
23
|
+
print(text, end="")
|
|
24
|
+
else:
|
|
25
|
+
output.write_text(text, encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _report(args: argparse.Namespace): # type: ignore[no-untyped-def]
|
|
29
|
+
return scan(load_config(args.config))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def command_scan(args: argparse.Namespace) -> int:
|
|
33
|
+
report = _report(args)
|
|
34
|
+
if args.format == "json":
|
|
35
|
+
rendered = json_text(report.as_dict())
|
|
36
|
+
elif args.format == "sarif":
|
|
37
|
+
findings = [
|
|
38
|
+
{
|
|
39
|
+
"rule_id": "ctxfire/context-surface",
|
|
40
|
+
"title": "Agent context surface",
|
|
41
|
+
"help": (
|
|
42
|
+
"Review the versioned adapter assumptions and use ctxfire explain "
|
|
43
|
+
"for attribution."
|
|
44
|
+
),
|
|
45
|
+
"level": "note",
|
|
46
|
+
"message": (
|
|
47
|
+
f"{agent.name}: estimated {agent.estimated_tokens_per_day} input tokens/day."
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
for agent in report.agents
|
|
51
|
+
]
|
|
52
|
+
rendered = json_text(
|
|
53
|
+
sarif(findings, report.tool["version"], report.as_dict()["assumptions"])
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
rendered = scan_text(report)
|
|
57
|
+
_write(rendered, args.output)
|
|
58
|
+
return EXIT_OK
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def command_explain(args: argparse.Namespace) -> int:
|
|
62
|
+
report = _report(args)
|
|
63
|
+
if args.format == "json":
|
|
64
|
+
agents = []
|
|
65
|
+
for agent in report.as_dict()["agents"]:
|
|
66
|
+
if args.agent and agent["name"] != args.agent:
|
|
67
|
+
continue
|
|
68
|
+
agent["files"] = [
|
|
69
|
+
item for item in agent["files"] if not args.file or item["path"] == args.file
|
|
70
|
+
]
|
|
71
|
+
agents.append(agent)
|
|
72
|
+
rendered = json_text(
|
|
73
|
+
{
|
|
74
|
+
"schema_version": report.schema_version,
|
|
75
|
+
"tool": report.tool,
|
|
76
|
+
"assumptions": report.as_dict()["assumptions"],
|
|
77
|
+
"agents": agents,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
rendered = explain_text(report, args.agent, args.file)
|
|
82
|
+
_write(rendered, args.output)
|
|
83
|
+
return EXIT_OK
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_snapshot(path: Path) -> dict[str, Any]:
|
|
87
|
+
payload = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
|
|
88
|
+
if payload.get("schema_version") != "1.0" or not isinstance(payload.get("agents"), list):
|
|
89
|
+
raise ValueError(f"not a ctxfire report schema 1.0: {path}")
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def command_diff(args: argparse.Namespace) -> int:
|
|
94
|
+
before = _load_snapshot(args.before)
|
|
95
|
+
after = _load_snapshot(args.after)
|
|
96
|
+
old = {agent["name"]: agent for agent in before["agents"]}
|
|
97
|
+
new = {agent["name"]: agent for agent in after["agents"]}
|
|
98
|
+
changes = []
|
|
99
|
+
for name in sorted(old.keys() | new.keys()):
|
|
100
|
+
old_tokens = int(old.get(name, {}).get("estimated_tokens_per_day", 0))
|
|
101
|
+
new_tokens = int(new.get(name, {}).get("estimated_tokens_per_day", 0))
|
|
102
|
+
old_paths = {item["path"] for item in old.get(name, {}).get("files", [])}
|
|
103
|
+
new_paths = {item["path"] for item in new.get(name, {}).get("files", [])}
|
|
104
|
+
old_files = {item["path"]: item for item in old.get(name, {}).get("files", [])}
|
|
105
|
+
new_files = {item["path"]: item for item in new.get(name, {}).get("files", [])}
|
|
106
|
+
changed_files = []
|
|
107
|
+
for path in sorted(old_paths & new_paths):
|
|
108
|
+
old_file = old_files[path]
|
|
109
|
+
new_file = new_files[path]
|
|
110
|
+
compared = ("exact_bytes", "counted_bytes", "estimated_tokens", "activation_rate")
|
|
111
|
+
if any(old_file.get(field) != new_file.get(field) for field in compared):
|
|
112
|
+
changed_files.append(
|
|
113
|
+
{
|
|
114
|
+
"path": path,
|
|
115
|
+
"before": {field: old_file.get(field) for field in compared},
|
|
116
|
+
"after": {field: new_file.get(field) for field in compared},
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
changes.append(
|
|
120
|
+
{
|
|
121
|
+
"agent": name,
|
|
122
|
+
"fires_per_day_before": old.get(name, {}).get("fires_per_day", 0),
|
|
123
|
+
"fires_per_day_after": new.get(name, {}).get("fires_per_day", 0),
|
|
124
|
+
"estimated_tokens_per_day_before": old_tokens,
|
|
125
|
+
"estimated_tokens_per_day_after": new_tokens,
|
|
126
|
+
"estimated_tokens_per_day_delta": new_tokens - old_tokens,
|
|
127
|
+
"added_files": sorted(new_paths - old_paths),
|
|
128
|
+
"removed_files": sorted(old_paths - new_paths),
|
|
129
|
+
"changed_files": changed_files,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
payload = {
|
|
133
|
+
"schema_version": "1.0",
|
|
134
|
+
"kind": "ctxfire-diff",
|
|
135
|
+
"assumptions_before": before.get("assumptions"),
|
|
136
|
+
"assumptions_after": after.get("assumptions"),
|
|
137
|
+
"assumptions_changed": before.get("assumptions") != after.get("assumptions"),
|
|
138
|
+
"changes": changes,
|
|
139
|
+
}
|
|
140
|
+
if args.format == "json":
|
|
141
|
+
rendered = json_text(payload)
|
|
142
|
+
else:
|
|
143
|
+
lines = [
|
|
144
|
+
"ctxfire diff",
|
|
145
|
+
"Estimated token deltas use each snapshot's recorded assumptions.",
|
|
146
|
+
"",
|
|
147
|
+
]
|
|
148
|
+
if payload["assumptions_changed"]:
|
|
149
|
+
lines.append("WARNING: estimation assumptions changed between snapshots.")
|
|
150
|
+
for item in changes:
|
|
151
|
+
lines.append(
|
|
152
|
+
f"{item['agent']}: {item['estimated_tokens_per_day_delta']:+d} estimated tokens/day"
|
|
153
|
+
)
|
|
154
|
+
lines.extend(f" + {path}" for path in item["added_files"])
|
|
155
|
+
lines.extend(f" - {path}" for path in item["removed_files"])
|
|
156
|
+
for changed in item["changed_files"]:
|
|
157
|
+
lines.append(
|
|
158
|
+
f" ~ {changed['path']}: {changed['before']['exact_bytes']} -> "
|
|
159
|
+
f"{changed['after']['exact_bytes']} exact bytes; "
|
|
160
|
+
f"~{changed['before']['estimated_tokens']} -> "
|
|
161
|
+
f"~{changed['after']['estimated_tokens']} tokens"
|
|
162
|
+
)
|
|
163
|
+
if item["fires_per_day_before"] != item["fires_per_day_after"]:
|
|
164
|
+
lines.append(
|
|
165
|
+
f" schedule: {item['fires_per_day_before']:g} -> "
|
|
166
|
+
f"{item['fires_per_day_after']:g} fires/day"
|
|
167
|
+
)
|
|
168
|
+
rendered = "\n".join(lines) + "\n"
|
|
169
|
+
_write(rendered, args.output)
|
|
170
|
+
return EXIT_OK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def command_check(args: argparse.Namespace) -> int:
|
|
174
|
+
report = _report(args)
|
|
175
|
+
for option, value in (
|
|
176
|
+
("--max-tokens-per-fire", args.max_tokens_per_fire),
|
|
177
|
+
("--max-tokens-per-day", args.max_tokens_per_day),
|
|
178
|
+
("--max-usd-per-day", args.max_usd_per_day),
|
|
179
|
+
):
|
|
180
|
+
if value is not None and value < 0:
|
|
181
|
+
raise ValueError(f"{option} cannot be negative")
|
|
182
|
+
findings: list[dict[str, str]] = []
|
|
183
|
+
checks = [
|
|
184
|
+
(
|
|
185
|
+
"tokens-per-fire",
|
|
186
|
+
args.max_tokens_per_fire,
|
|
187
|
+
max((item.estimated_tokens_per_fire for item in report.agents), default=0),
|
|
188
|
+
),
|
|
189
|
+
("tokens-per-day", args.max_tokens_per_day, int(report.totals["estimated_tokens_per_day"])),
|
|
190
|
+
]
|
|
191
|
+
for name, limit, actual in checks:
|
|
192
|
+
if limit is not None and actual > limit:
|
|
193
|
+
findings.append(
|
|
194
|
+
{
|
|
195
|
+
"rule_id": f"ctxfire/{name}",
|
|
196
|
+
"title": f"Context budget exceeded: {name}",
|
|
197
|
+
"help": (
|
|
198
|
+
"Run ctxfire explain to attribute context files, then adjust "
|
|
199
|
+
"context or the explicit budget."
|
|
200
|
+
),
|
|
201
|
+
"level": "error",
|
|
202
|
+
"message": f"Estimated {name} is {actual}, above configured CLI limit {limit}.",
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
if args.max_usd_per_day is not None:
|
|
206
|
+
cost = report.totals["estimated_usd_per_day"]
|
|
207
|
+
if cost is None:
|
|
208
|
+
raise ValueError("--max-usd-per-day requires usd_per_million_input_tokens in config")
|
|
209
|
+
if float(cost) > args.max_usd_per_day:
|
|
210
|
+
findings.append(
|
|
211
|
+
{
|
|
212
|
+
"rule_id": "ctxfire/usd-per-day",
|
|
213
|
+
"title": "API-equivalent cost budget exceeded",
|
|
214
|
+
"help": (
|
|
215
|
+
"Review the dated price and cache assumptions before changing the budget."
|
|
216
|
+
),
|
|
217
|
+
"level": "error",
|
|
218
|
+
"message": (
|
|
219
|
+
"Estimated API-equivalent input cost/day is "
|
|
220
|
+
f"${float(cost):.6f}, above ${args.max_usd_per_day:.6f}."
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
if args.format == "sarif":
|
|
225
|
+
rendered = json_text(
|
|
226
|
+
sarif(findings, report.tool["version"], report.as_dict()["assumptions"])
|
|
227
|
+
)
|
|
228
|
+
elif args.format == "json":
|
|
229
|
+
rendered = json_text(
|
|
230
|
+
{
|
|
231
|
+
"schema_version": "1.0",
|
|
232
|
+
"passed": not findings,
|
|
233
|
+
"findings": findings,
|
|
234
|
+
"report": report.as_dict(),
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
assumptions = report.assumptions
|
|
239
|
+
rendered = (
|
|
240
|
+
("ctxfire check: PASS\n" if not findings else "ctxfire check: FAIL\n")
|
|
241
|
+
+ (
|
|
242
|
+
f"Estimates: {assumptions.tokenizer} ({assumptions.tokenizer_version}), "
|
|
243
|
+
f"{assumptions.bytes_per_token:g} bytes/token, model {assumptions.model}, "
|
|
244
|
+
f"price date {assumptions.price_date}, cache {assumptions.cache_assumption}.\n"
|
|
245
|
+
)
|
|
246
|
+
+ "\n".join(f" - {item['message']}" for item in findings)
|
|
247
|
+
+ ("\n" if findings else "")
|
|
248
|
+
)
|
|
249
|
+
_write(rendered, args.output)
|
|
250
|
+
return EXIT_OK if not findings else EXIT_BUDGET_EXCEEDED
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _common(parser: argparse.ArgumentParser, formats: tuple[str, ...]) -> None:
|
|
254
|
+
parser.add_argument("--config", type=Path, default=Path("ctxfire.toml"))
|
|
255
|
+
parser.add_argument("--format", choices=formats, default=formats[0])
|
|
256
|
+
parser.add_argument("--output", type=Path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
260
|
+
parser = argparse.ArgumentParser(
|
|
261
|
+
prog="ctxfire",
|
|
262
|
+
description="Explain and budget the static context graph of coding agents.",
|
|
263
|
+
)
|
|
264
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
265
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
266
|
+
scan_parser = subparsers.add_parser(
|
|
267
|
+
"scan", help="build a context graph and estimate its daily cost"
|
|
268
|
+
)
|
|
269
|
+
_common(scan_parser, ("text", "json", "sarif"))
|
|
270
|
+
scan_parser.set_defaults(handler=command_scan)
|
|
271
|
+
explain_parser = subparsers.add_parser("explain", help="show why each file is included")
|
|
272
|
+
_common(explain_parser, ("text", "json"))
|
|
273
|
+
explain_parser.add_argument("--agent")
|
|
274
|
+
explain_parser.add_argument("--file")
|
|
275
|
+
explain_parser.set_defaults(handler=command_explain)
|
|
276
|
+
diff_parser = subparsers.add_parser("diff", help="compare two JSON scan snapshots")
|
|
277
|
+
diff_parser.add_argument("before", type=Path)
|
|
278
|
+
diff_parser.add_argument("after", type=Path)
|
|
279
|
+
diff_parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
280
|
+
diff_parser.add_argument("--output", type=Path)
|
|
281
|
+
diff_parser.set_defaults(handler=command_diff)
|
|
282
|
+
check_parser = subparsers.add_parser("check", help="enforce stable CI budgets")
|
|
283
|
+
_common(check_parser, ("text", "json", "sarif"))
|
|
284
|
+
check_parser.add_argument("--max-tokens-per-fire", type=int)
|
|
285
|
+
check_parser.add_argument("--max-tokens-per-day", type=int)
|
|
286
|
+
check_parser.add_argument("--max-usd-per-day", type=float)
|
|
287
|
+
check_parser.set_defaults(handler=command_check)
|
|
288
|
+
return parser
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: list[str] | None = None) -> int:
|
|
292
|
+
try:
|
|
293
|
+
args = build_parser().parse_args(argv)
|
|
294
|
+
return int(args.handler(args))
|
|
295
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
296
|
+
print(f"ctxfire: error: {error}", file=sys.stderr)
|
|
297
|
+
return EXIT_ERROR
|
ctxfire/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Configuration parsing and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .model import CONFIG_SCHEMA_VERSION, Assumptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AgentConfig:
|
|
16
|
+
name: str
|
|
17
|
+
adapter: str
|
|
18
|
+
engine: str
|
|
19
|
+
working_directory: str
|
|
20
|
+
fires_per_day: float
|
|
21
|
+
include: tuple[str, ...]
|
|
22
|
+
conditional: tuple[str, ...]
|
|
23
|
+
exclude: tuple[str, ...]
|
|
24
|
+
instruction_fallback_filenames: tuple[str, ...]
|
|
25
|
+
instruction_max_bytes: int | None
|
|
26
|
+
claude_rules_activation: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Config:
|
|
31
|
+
path: Path
|
|
32
|
+
root: Path
|
|
33
|
+
project_name: str
|
|
34
|
+
assumptions: Assumptions
|
|
35
|
+
agents: tuple[AgentConfig, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _patterns(raw: dict[str, Any], key: str) -> tuple[str, ...]:
|
|
39
|
+
value = raw.get(key, [])
|
|
40
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
41
|
+
raise ValueError(f"{key} must be an array of strings")
|
|
42
|
+
for pattern in value:
|
|
43
|
+
pure = PurePosixPath(pattern)
|
|
44
|
+
if pure.is_absolute() or ".." in pure.parts:
|
|
45
|
+
raise ValueError(f"{key} pattern must stay inside project root: {pattern}")
|
|
46
|
+
return tuple(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: Path) -> Config:
|
|
50
|
+
"""Load a ctxfire v1 TOML file without reading repository content."""
|
|
51
|
+
|
|
52
|
+
resolved = path.resolve()
|
|
53
|
+
data = tomllib.loads(resolved.read_text(encoding="utf-8"))
|
|
54
|
+
if str(data.get("schema_version", "")) != CONFIG_SCHEMA_VERSION:
|
|
55
|
+
raise ValueError(f'schema_version must be "{CONFIG_SCHEMA_VERSION}"')
|
|
56
|
+
project = data.get("project", {})
|
|
57
|
+
if not isinstance(project, dict):
|
|
58
|
+
raise ValueError("[project] must be a table")
|
|
59
|
+
root_value = Path(str(project.get("root", ".")))
|
|
60
|
+
root = root_value if root_value.is_absolute() else resolved.parent / root_value
|
|
61
|
+
root = root.resolve()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
raise ValueError(f"project root is not a directory: {root}")
|
|
64
|
+
|
|
65
|
+
bytes_per_token = float(project.get("bytes_per_token", 4.0))
|
|
66
|
+
conditional_rate = float(project.get("conditional_activation_rate", 0.0))
|
|
67
|
+
if bytes_per_token <= 0:
|
|
68
|
+
raise ValueError("bytes_per_token must be greater than zero")
|
|
69
|
+
if not 0 <= conditional_rate <= 1:
|
|
70
|
+
raise ValueError("conditional_activation_rate must be between 0 and 1")
|
|
71
|
+
price_raw = project.get("usd_per_million_input_tokens")
|
|
72
|
+
price = None if price_raw is None else float(price_raw)
|
|
73
|
+
if price is not None and price < 0:
|
|
74
|
+
raise ValueError("usd_per_million_input_tokens cannot be negative")
|
|
75
|
+
tokenizer = str(project.get("tokenizer", "byte-estimate"))
|
|
76
|
+
if tokenizer == "byte-estimate":
|
|
77
|
+
tokenizer_version = "approximation-v1"
|
|
78
|
+
elif tokenizer.startswith("tiktoken:") and tokenizer.partition(":")[2]:
|
|
79
|
+
try:
|
|
80
|
+
tokenizer_version = version("tiktoken")
|
|
81
|
+
except PackageNotFoundError as error:
|
|
82
|
+
raise ValueError("tiktoken tokenizer requested; install ctxfire[tokenizers]") from error
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError("tokenizer must be byte-estimate or tiktoken:<encoding>")
|
|
85
|
+
assumptions = Assumptions(
|
|
86
|
+
tokenizer=tokenizer,
|
|
87
|
+
tokenizer_version=tokenizer_version,
|
|
88
|
+
bytes_per_token=bytes_per_token,
|
|
89
|
+
model=str(project.get("model", "unspecified")),
|
|
90
|
+
price_date=str(project.get("price_date", "unspecified")),
|
|
91
|
+
usd_per_million_input_tokens=price,
|
|
92
|
+
cache_assumption=str(project.get("cache_assumption", "no-cache-credit")),
|
|
93
|
+
conditional_activation_rate=conditional_rate,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
raw_agents = data.get("agents", [])
|
|
97
|
+
if not isinstance(raw_agents, list) or not raw_agents:
|
|
98
|
+
raise ValueError("configure at least one [[agents]] table")
|
|
99
|
+
agents: list[AgentConfig] = []
|
|
100
|
+
names: set[str] = set()
|
|
101
|
+
for raw in raw_agents:
|
|
102
|
+
if not isinstance(raw, dict) or not str(raw.get("name", "")).strip():
|
|
103
|
+
raise ValueError("every [[agents]] table needs a non-empty name")
|
|
104
|
+
name = str(raw["name"])
|
|
105
|
+
if name in names:
|
|
106
|
+
raise ValueError(f"duplicate agent name: {name}")
|
|
107
|
+
names.add(name)
|
|
108
|
+
fires = float(raw.get("fires_per_day", 1.0))
|
|
109
|
+
if fires < 0:
|
|
110
|
+
raise ValueError(f"fires_per_day cannot be negative for {name}")
|
|
111
|
+
working_directory = str(raw.get("working_directory", ".")).strip("/") or "."
|
|
112
|
+
work_path = PurePosixPath(working_directory)
|
|
113
|
+
if work_path.is_absolute() or ".." in work_path.parts:
|
|
114
|
+
raise ValueError(f"working_directory must stay inside project root: {name}")
|
|
115
|
+
adapter = str(raw.get("adapter", "explicit@1"))
|
|
116
|
+
if adapter not in {"explicit@1", "agents-md@1", "codex@1", "claude-code@1"}:
|
|
117
|
+
raise ValueError(f"unsupported adapter {adapter!r} for {name}")
|
|
118
|
+
fallback_names = _patterns(raw, "instruction_fallback_filenames")
|
|
119
|
+
if any("/" in item or "\\" in item for item in fallback_names):
|
|
120
|
+
raise ValueError(f"instruction fallback names must be filenames for {name}")
|
|
121
|
+
max_bytes_raw = raw.get("instruction_max_bytes", 32768 if adapter == "codex@1" else None)
|
|
122
|
+
instruction_max_bytes = None if max_bytes_raw is None else int(max_bytes_raw)
|
|
123
|
+
if instruction_max_bytes is not None and instruction_max_bytes <= 0:
|
|
124
|
+
raise ValueError(f"instruction_max_bytes must be positive for {name}")
|
|
125
|
+
claude_rules_activation = str(raw.get("claude_rules_activation", "always"))
|
|
126
|
+
if claude_rules_activation not in {"always", "conditional"}:
|
|
127
|
+
raise ValueError(f"claude_rules_activation must be always or conditional for {name}")
|
|
128
|
+
agents.append(
|
|
129
|
+
AgentConfig(
|
|
130
|
+
name=name,
|
|
131
|
+
adapter=adapter,
|
|
132
|
+
engine=str(raw.get("engine", adapter.partition("@")[0])),
|
|
133
|
+
working_directory=working_directory,
|
|
134
|
+
fires_per_day=fires,
|
|
135
|
+
include=_patterns(raw, "include"),
|
|
136
|
+
conditional=_patterns(raw, "conditional"),
|
|
137
|
+
exclude=_patterns(raw, "exclude"),
|
|
138
|
+
instruction_fallback_filenames=fallback_names,
|
|
139
|
+
instruction_max_bytes=instruction_max_bytes,
|
|
140
|
+
claude_rules_activation=claude_rules_activation,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return Config(
|
|
144
|
+
path=resolved,
|
|
145
|
+
root=root,
|
|
146
|
+
project_name=str(project.get("name", root.name)),
|
|
147
|
+
assumptions=assumptions,
|
|
148
|
+
agents=tuple(agents),
|
|
149
|
+
)
|