greedy-token 0.2.1__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.
@@ -0,0 +1,3 @@
1
+ """Greedy-token: route dev tasks before expensive LLM calls."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from greedy_token.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
greedy_token/cli.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from greedy_token.context_audit import audit_context, render_audit
7
+ from greedy_token.estimator import estimate_task, format_estimate
8
+ from greedy_token.executors import execute_plan, plan_run
9
+ from greedy_token.paths import find_monorepo_root
10
+ from greedy_token.prompt_compress import compress_prompt, format_dual
11
+ from greedy_token.rag_search import format_hits, search_rag
12
+ from greedy_token.router import format_decision, route_task
13
+ from greedy_token.tokens import TokenEstimate, collect_paths, count_files, format_size_table
14
+ from greedy_token.wrappers import WRAPPERS, ollama_status_line, resolve_wrapper_command
15
+
16
+
17
+ def cmd_route(args: argparse.Namespace) -> int:
18
+ root = find_monorepo_root()
19
+ decision = route_task(args.task, root)
20
+ print(format_decision(decision, args.task, root))
21
+ return 0
22
+
23
+
24
+ def cmd_estimate(args: argparse.Namespace) -> int:
25
+ root = find_monorepo_root()
26
+ estimate = estimate_task(args.task, root)
27
+ print(format_estimate(estimate, args.task, root))
28
+ return 0
29
+
30
+
31
+ def cmd_run(args: argparse.Namespace) -> int:
32
+ root = find_monorepo_root()
33
+ decision = route_task(args.task, root)
34
+ plan = plan_run(decision, args.task, root)
35
+ print(f"Route: {decision.target} ({decision.route_id})")
36
+ print(f"Complexity: {decision.complexity} Est. tokens: {decision.est_tokens:,}")
37
+ print()
38
+ if args.execute:
39
+ code, out = execute_plan(plan)
40
+ print(out)
41
+ return code
42
+ print(plan.dry_run_output)
43
+ if plan.command:
44
+ if plan.executable:
45
+ print("\n(read-only — add --execute to run)")
46
+ else:
47
+ print("\n(not read-only — dry-run only)")
48
+ return 0
49
+
50
+
51
+ def cmd_audit_context(_: argparse.Namespace) -> int:
52
+ items = audit_context()
53
+ print(render_audit(items))
54
+ return 0
55
+
56
+
57
+ def cmd_tokens(args: argparse.Namespace) -> int:
58
+ root = find_monorepo_root()
59
+ paths = collect_paths(args.paths, root)
60
+ if not paths:
61
+ print("No files found.", file=sys.stderr)
62
+ return 1
63
+ estimates = count_files(paths)
64
+ rows = []
65
+ total_chars = 0
66
+ total_tokens = 0
67
+ method = "heuristic/4"
68
+ for p, est in zip(paths, estimates):
69
+ rel = str(p.relative_to(root)) if p.is_relative_to(root) else str(p)
70
+ rows.append((rel, est))
71
+ total_chars += est.chars
72
+ total_tokens += est.tokens
73
+ method = est.method
74
+ rows.sort(key=lambda r: -r[1].tokens)
75
+ total = TokenEstimate(tokens=total_tokens, chars=total_chars, method=method)
76
+ print(format_size_table(rows, total))
77
+ return 0
78
+
79
+
80
+ def cmd_rag(args: argparse.Namespace) -> int:
81
+ root = find_monorepo_root()
82
+ domains = args.domain.split(",") if args.domain else None
83
+ hits = search_rag(args.query, root, domains=domains, limit=args.limit)
84
+ print(format_hits(args.query, hits))
85
+ return 0
86
+
87
+
88
+ def cmd_compress(args: argparse.Namespace) -> int:
89
+ text = sys.stdin.read()
90
+ if not text.strip():
91
+ print("Read prompt from stdin.", file=sys.stderr)
92
+ return 1
93
+ short = compress_prompt(text, use_ollama=args.ollama)
94
+ if args.raw:
95
+ print(short)
96
+ else:
97
+ print(format_dual(text, short))
98
+ return 0
99
+
100
+
101
+ def cmd_scripts(args: argparse.Namespace) -> int:
102
+ root = find_monorepo_root()
103
+ if args.list:
104
+ lines = ["Script wrappers (scripts/ollama | migrate | check-meta-sync):", ""]
105
+ for wrapper in WRAPPERS.values():
106
+ ro = "read-only" if wrapper.read_only else "writes"
107
+ oll = " +ollama" if wrapper.requires_ollama else ""
108
+ lines.append(f" {wrapper.id:<20} [{wrapper.category}] {ro}{oll}")
109
+ lines.append(f" {wrapper.path}")
110
+ if wrapper.note:
111
+ lines.append(f" {wrapper.note}")
112
+ lines.append("")
113
+ lines.append(ollama_status_line())
114
+ print("\n".join(lines))
115
+ return 0
116
+ if args.run:
117
+ try:
118
+ cmd = resolve_wrapper_command(args.run, root, extra_args=args.args or "")
119
+ except (KeyError, FileNotFoundError) as exc:
120
+ print(exc, file=sys.stderr)
121
+ return 1
122
+ wrapper = WRAPPERS[args.run]
123
+ if args.execute:
124
+ if not wrapper.read_only:
125
+ print(
126
+ f"Refusing --execute: {args.run} is not read-only.",
127
+ file=sys.stderr,
128
+ )
129
+ return 1
130
+ import subprocess
131
+
132
+ proc = subprocess.run(cmd, shell=True)
133
+ return proc.returncode
134
+ print(cmd)
135
+ if wrapper.read_only:
136
+ print("\n(read-only — add --execute to run)")
137
+ else:
138
+ print("\n(not read-only — dry-run only)")
139
+ return 0
140
+ print("Use scripts --list or scripts --run ID", file=sys.stderr)
141
+ return 1
142
+
143
+
144
+ def build_parser() -> argparse.ArgumentParser:
145
+ p = argparse.ArgumentParser(
146
+ prog="greedy-token",
147
+ description="Task orchestrator: tool | Python | Ollama | RAG | Cursor",
148
+ )
149
+ sub = p.add_subparsers(dest="command", required=True)
150
+
151
+ r = sub.add_parser("route", help="Recommend executor for a task")
152
+ r.add_argument("task", help="Natural language task description")
153
+ r.set_defaults(func=cmd_route)
154
+
155
+ est = sub.add_parser("estimate", help="Token-aware route estimate with tier scan")
156
+ est.add_argument("task", help="Task description")
157
+ est.set_defaults(func=cmd_estimate)
158
+
159
+ run = sub.add_parser("run", help="Route and show/run command")
160
+ run.add_argument("task", help="Task description")
161
+ run.add_argument(
162
+ "--execute",
163
+ action="store_true",
164
+ help="Execute read-only tool/python commands only",
165
+ )
166
+ run.set_defaults(func=cmd_run)
167
+
168
+ sub.add_parser("audit-context", help="Token audit of rules/skills").set_defaults(
169
+ func=cmd_audit_context
170
+ )
171
+
172
+ t = sub.add_parser("tokens", help="Count tokens in paths")
173
+ t.add_argument("paths", nargs="+", help="Files or directories")
174
+ t.set_defaults(func=cmd_tokens)
175
+
176
+ rag = sub.add_parser("rag", help="Search docs/rag chunks")
177
+ rag.add_argument("query", help="Search query")
178
+ rag.add_argument("--domain", help="Comma-separated domains filter")
179
+ rag.add_argument("--limit", type=int, default=5)
180
+ rag.set_defaults(func=cmd_rag)
181
+
182
+ c = sub.add_parser("compress", help="Short agent prompt from stdin")
183
+ c.add_argument("--ollama", action="store_true", help="Use local Ollama")
184
+ c.add_argument("--raw", action="store_true", help="Print short text only")
185
+ c.set_defaults(func=cmd_compress)
186
+
187
+ scr = sub.add_parser("scripts", help="Wrappers for monorepo scripts")
188
+ scr.add_argument("--list", action="store_true", help="List script wrappers")
189
+ scr.add_argument("--run", metavar="ID", help="Wrapper id (e.g. check-meta-sync)")
190
+ scr.add_argument("args", nargs="?", default="", help="Extra args for script")
191
+ scr.add_argument(
192
+ "--execute",
193
+ action="store_true",
194
+ help="Run read-only wrapper only",
195
+ )
196
+ scr.set_defaults(func=cmd_scripts)
197
+
198
+ return p
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> None:
202
+ parser = build_parser()
203
+ args = parser.parse_args(argv)
204
+ code = args.func(args)
205
+ raise SystemExit(code)
@@ -0,0 +1,156 @@
1
+ # Task routing — aligned with docs/migration-prompts.md § Token economy rules
2
+ #
3
+ # Tier order (tool-first): tool → python → ollama → rag → cursor
4
+ # First matching tier wins (best score within tier).
5
+
6
+ routes:
7
+ - id: tool-rg-search
8
+ target: tool
9
+ tool: rg
10
+ read_only: true
11
+ patterns:
12
+ - find
13
+ - search for
14
+ - grep
15
+ - where is
16
+ - locate
17
+ - look for
18
+ note: "0 tokens — ripgrep before RAG/Cursor"
19
+
20
+ - id: tool-jq-manifest
21
+ target: tool
22
+ tool: jq
23
+ read_only: true
24
+ patterns:
25
+ - jq
26
+ - json field
27
+ - parse json
28
+ - phase-manifest json
29
+ json_path: docs/phase-manifest.json
30
+ jq_filter: .
31
+ note: "Structured JSON lookup — jq, 0 LLM"
32
+
33
+ - id: python-rsync
34
+ target: python
35
+ read_only: false
36
+ patterns:
37
+ - rsync
38
+ - migrate
39
+ - phase1-rsync
40
+ - mechanical copy
41
+ - bulk copy
42
+ command: ./scripts/migrate/phase1-rsync.sh
43
+ note: "0 tokens — shell rsync"
44
+
45
+ - id: python-meta-sync
46
+ target: python
47
+ read_only: true
48
+ patterns:
49
+ - check-meta-sync
50
+ - meta sync
51
+ - phase-manifest
52
+ - skills-map sync
53
+ command: ./scripts/check-meta-sync.sh
54
+ note: "Meta validation without LLM"
55
+
56
+ - id: python-gen-env
57
+ target: python
58
+ read_only: false
59
+ patterns:
60
+ - gen-env-configs
61
+ - env config generate
62
+ - properties sync script
63
+ command: python stacks/java-spring/scripts/gen-env-configs.py
64
+ note: "Deterministic config generation"
65
+
66
+ - id: ollama-inventory
67
+ target: ollama
68
+ read_only: false
69
+ patterns:
70
+ - batch inventory
71
+ - classify files
72
+ - file inventory
73
+ - migration inventory
74
+ - apply-inventory
75
+ command: ./scripts/ollama/batch-inventory.sh
76
+ note: "Bulk classify via local LLM"
77
+
78
+ - id: ollama-audit-skill
79
+ target: ollama
80
+ read_only: false
81
+ patterns:
82
+ - audit skill
83
+ - skill audit
84
+ - review skill md
85
+ command: ./scripts/ollama/audit-skill.sh
86
+ note: "Skill quality audit — Ollama, not Cursor"
87
+
88
+ - id: ollama-rag-draft
89
+ target: ollama
90
+ read_only: false
91
+ patterns:
92
+ - draft rag
93
+ - rag chunk draft
94
+ - batch rag
95
+ - ollama chunk
96
+ command: null
97
+ note: "Draft RAG chunks via scripts/ollama/ — never one-by-one in Cursor"
98
+
99
+ - id: rag-e2e-config
100
+ target: rag
101
+ patterns:
102
+ - e2e config
103
+ - -d flag
104
+ - testconfig
105
+ - env profile
106
+ - baseurl
107
+ - base url
108
+ - healthcheck
109
+ - gradle -d
110
+ domains: [e2e]
111
+ note: "Lookup docs/rag/e2e/e2e-config-keys.md, cfg-*"
112
+
113
+ - id: rag-e2e-patterns
114
+ target: rag
115
+ patterns:
116
+ - page object
117
+ - po locator
118
+ - selenide
119
+ - test pyramid
120
+ - test layer
121
+ - allure attach
122
+ - ci workflow
123
+ - allurerc
124
+ domains: [e2e, e2e-header]
125
+ note: "e2e patterns — read chunk, don't re-derive in chat"
126
+
127
+ - id: rag-stacks
128
+ target: rag
129
+ patterns:
130
+ - stack template
131
+ - openapi contract
132
+ - pyramid-map
133
+ - flows/login
134
+ domains: [stacks]
135
+ note: "stacks/_contract + docs/rag/stacks/"
136
+
137
+ - id: cursor-wiring
138
+ target: cursor
139
+ patterns:
140
+ - implement
141
+ - refactor
142
+ - fix bug
143
+ - add component
144
+ - header layout
145
+ - spring backend
146
+ - wiring
147
+ - architecture
148
+ - new feature
149
+ - e2e test class
150
+ note: "One chat = one zone; use skill if listed"
151
+
152
+ cursor_fallback:
153
+ message: |
154
+ Нет точного match — скорее всего Cursor/Claude.
155
+ Проверь: один чат = одна зона (agent-scope.mdc).
156
+ Перед чатом: greedy-token audit-context && greedy-token rag "<topic>".
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from greedy_token.paths import find_monorepo_root
7
+ from greedy_token.tokens import TokenEstimate, count_files, format_size_table
8
+
9
+
10
+ @dataclass
11
+ class ContextItem:
12
+ path: str
13
+ kind: str
14
+ always_on: bool
15
+ estimate: TokenEstimate
16
+
17
+
18
+ def audit_context(root: Path | None = None) -> list[ContextItem]:
19
+ root = root or find_monorepo_root()
20
+ items: list[ContextItem] = []
21
+
22
+ globs = [
23
+ (".cursor/rules/*.mdc", "rule", True),
24
+ (".cursor/skills/*/SKILL.md", "skill", False),
25
+ ("docs/CONTEXT.md", "doc", False),
26
+ ("docs/migration-prompts.md", "doc", False),
27
+ ]
28
+
29
+ found: list[tuple[Path, str, bool]] = []
30
+ for pattern, kind, always_on in globs:
31
+ for path in sorted(root.glob(pattern)):
32
+ if not path.is_file():
33
+ continue
34
+ found.append((path, kind, always_on))
35
+
36
+ estimates = count_files([f[0] for f in found])
37
+ for (path, kind, always_on), estimate in zip(found, estimates):
38
+ items.append(
39
+ ContextItem(
40
+ path=str(path.relative_to(root)),
41
+ kind=kind,
42
+ always_on=always_on,
43
+ estimate=estimate,
44
+ )
45
+ )
46
+
47
+ return items
48
+
49
+
50
+ def render_audit(items: list[ContextItem]) -> str:
51
+ always = [i for i in items if i.always_on]
52
+ rules_total = sum(i.estimate.tokens for i in always)
53
+ skills_total = sum(i.estimate.tokens for i in items if i.kind == "skill")
54
+ all_total = sum(i.estimate.tokens for i in items)
55
+
56
+ lines = [
57
+ "== Cursor context audit ==",
58
+ "",
59
+ f"Always-on rules (.cursor/rules/*.mdc): {rules_total:,} tokens",
60
+ f"Skills on disk (.cursor/skills/*/SKILL.md): {skills_total:,} tokens",
61
+ f"Sampled docs: {all_total - rules_total - skills_total:,} tokens",
62
+ f"Grand total (sampled set): {all_total:,} tokens",
63
+ "",
64
+ "Always-on rules (charged every chat):",
65
+ ]
66
+
67
+ rule_rows = [
68
+ (i.path, i.estimate) for i in sorted(always, key=lambda x: -x.estimate.tokens)
69
+ ]
70
+ if rule_rows:
71
+ total = TokenEstimate(
72
+ tokens=rules_total,
73
+ chars=sum(i.estimate.chars for i in always),
74
+ method=always[0].estimate.method if always else "n/a",
75
+ )
76
+ lines.append(format_size_table(rule_rows, total))
77
+ else:
78
+ lines.append(" (none)")
79
+
80
+ lines.extend(["", "Top skills by size:"])
81
+ top_skills = sorted(
82
+ [i for i in items if i.kind == "skill"], key=lambda x: -x.estimate.tokens
83
+ )[:10]
84
+ for s in top_skills:
85
+ lines.append(f" {s.estimate.tokens:>6} {s.path}")
86
+
87
+ cache_hint = 1024
88
+ if rules_total >= cache_hint:
89
+ lines.extend(
90
+ [
91
+ "",
92
+ f"Note: always-on rules ({rules_total:,} tok) ≥ {cache_hint} —",
93
+ "stable prefix is cache-friendly for Claude API prompt caching.",
94
+ ]
95
+ )
96
+ elif rules_total > 0:
97
+ lines.extend(
98
+ [
99
+ "",
100
+ f"Note: always-on rules ({rules_total:,} tok) < {cache_hint} —",
101
+ "below typical prompt-cache minimum; keep rules slim.",
102
+ ]
103
+ )
104
+
105
+ return "\n".join(lines)
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from greedy_token.context_audit import audit_context
7
+ from greedy_token.router import RouteDecision, route_task, route_task_all_tiers
8
+ from greedy_token.tokens import count_tokens
9
+ from greedy_token.wrappers import ollama_available, ollama_status_line
10
+
11
+
12
+ TIER_ORDER = ("tool", "python", "ollama", "rag", "cursor")
13
+
14
+ COMPLEXITY_BY_TARGET = {
15
+ "tool": "low",
16
+ "python": "low",
17
+ "ollama": "medium",
18
+ "rag": "low",
19
+ "cursor": "high",
20
+ }
21
+
22
+ BASE_CURSOR_OVERHEAD = 6000
23
+
24
+
25
+ @dataclass
26
+ class TaskEstimate:
27
+ decision: RouteDecision
28
+ complexity: str
29
+ est_tokens: int
30
+ rationale: str
31
+ cursor_saved: int
32
+ ollama_note: str | None = None
33
+
34
+
35
+ def _cursor_baseline_tokens(root: Path) -> int:
36
+ items = audit_context(root)
37
+ return sum(i.estimate.tokens for i in items if i.always_on)
38
+
39
+
40
+ def estimate_task(task: str, root: Path) -> TaskEstimate:
41
+ decision = route_task(task, root)
42
+ complexity = decision.complexity or COMPLEXITY_BY_TARGET.get(decision.target, "medium")
43
+ est_tokens = decision.est_tokens
44
+ rationale = decision.rationale
45
+ cursor_saved = 0
46
+ ollama_note: str | None = None
47
+
48
+ if decision.target != "cursor":
49
+ baseline = _cursor_baseline_tokens(root)
50
+ task_tokens = count_tokens(task).tokens
51
+ cursor_equiv = baseline + task_tokens + BASE_CURSOR_OVERHEAD
52
+ cursor_saved = max(0, cursor_equiv - est_tokens)
53
+
54
+ if decision.target == "ollama" and not ollama_available():
55
+ ollama_note = ollama_status_line()
56
+
57
+ return TaskEstimate(
58
+ decision=decision,
59
+ complexity=complexity,
60
+ est_tokens=est_tokens,
61
+ rationale=rationale,
62
+ cursor_saved=cursor_saved,
63
+ ollama_note=ollama_note,
64
+ )
65
+
66
+
67
+ def format_estimate(estimate: TaskEstimate, task: str, root: Path) -> str:
68
+ d = estimate.decision
69
+ lines = [
70
+ f"Task: {task}",
71
+ f"Route: {d.target.upper()} ({d.route_id}, {d.confidence:.0%})",
72
+ f"Complexity: {estimate.complexity}",
73
+ f"Est. Cursor tokens: {estimate.est_tokens:,}",
74
+ f"Rationale: {estimate.rationale}",
75
+ ]
76
+ if d.matched:
77
+ lines.append(f"Matched: {', '.join(d.matched)}")
78
+ if d.command:
79
+ cmd = d.command if d.command.startswith("cd ") else f"cd {root} && {d.command}"
80
+ lines.append(f"Command: {cmd}")
81
+ if estimate.cursor_saved > 0:
82
+ lines.append(f"Cursor tokens saved vs agent chat: ~{estimate.cursor_saved:,}")
83
+ if estimate.ollama_note:
84
+ lines.append(f"Note: {estimate.ollama_note}")
85
+ if d.target == "cursor":
86
+ lines.append("→ Новый Cursor-чат; skill из docs/skills-map.md если есть.")
87
+ lines.extend(["", "Tier scan:"])
88
+ for tier, decision in route_task_all_tiers(task, root):
89
+ if decision.matched:
90
+ tag = " ← selected" if decision.route_id == d.route_id else ""
91
+ lines.append(
92
+ f" {tier:<8} {decision.route_id:<22} "
93
+ f"complexity={decision.complexity} est={decision.est_tokens:,}{tag}"
94
+ )
95
+ else:
96
+ lines.append(f" {tier:<8} —")
97
+ return "\n".join(lines)
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from greedy_token.paths import find_monorepo_root
8
+ from greedy_token.rag_search import format_hits, search_rag
9
+ from greedy_token.router import RouteDecision
10
+ from greedy_token.wrappers import wrapper_for_command
11
+
12
+
13
+ @dataclass
14
+ class RunPlan:
15
+ decision: RouteDecision
16
+ command: str | None
17
+ dry_run_output: str
18
+ executable: bool
19
+
20
+
21
+ def plan_run(decision: RouteDecision, task: str, root: Path | None = None) -> RunPlan:
22
+ root = root or find_monorepo_root()
23
+ target = decision.target
24
+
25
+ if target == "tool" and decision.command:
26
+ return RunPlan(
27
+ decision=decision,
28
+ command=decision.command,
29
+ dry_run_output=decision.command,
30
+ executable=decision.read_only,
31
+ )
32
+
33
+ if target == "python" and decision.command:
34
+ cmd = f"cd {root} && {decision.command}"
35
+ wrapper = wrapper_for_command(decision.command)
36
+ read_only = decision.read_only or (wrapper.read_only if wrapper else False)
37
+ return RunPlan(
38
+ decision=decision,
39
+ command=cmd,
40
+ dry_run_output=cmd,
41
+ executable=read_only,
42
+ )
43
+
44
+ if target == "ollama" and decision.command:
45
+ cmd = f"cd {root} && {decision.command}"
46
+ return RunPlan(
47
+ decision=decision,
48
+ command=cmd,
49
+ dry_run_output=cmd + " # pass args as needed",
50
+ executable=False,
51
+ )
52
+
53
+ if target == "rag":
54
+ hits = search_rag(task, root, domains=decision.domains or None)
55
+ return RunPlan(
56
+ decision=decision,
57
+ command=None,
58
+ dry_run_output=format_hits(task, hits),
59
+ executable=False,
60
+ )
61
+
62
+ if target == "cursor":
63
+ return RunPlan(
64
+ decision=decision,
65
+ command=None,
66
+ dry_run_output=(
67
+ "Open new Cursor chat.\n"
68
+ f"Task: {task}\n"
69
+ "Before paste: greedy-token audit-context && greedy-token rag \"<topic>\""
70
+ ),
71
+ executable=False,
72
+ )
73
+
74
+ return RunPlan(
75
+ decision=decision,
76
+ command=None,
77
+ dry_run_output="No executor.",
78
+ executable=False,
79
+ )
80
+
81
+
82
+ def execute_plan(plan: RunPlan) -> tuple[int, str]:
83
+ if not plan.command:
84
+ return 0, plan.dry_run_output
85
+ if not plan.executable:
86
+ return 1, (
87
+ "Refusing --execute: route is not read-only.\n"
88
+ f"Dry-run:\n{plan.dry_run_output}\n\n"
89
+ "Run the script manually if side effects are intended."
90
+ )
91
+ proc = subprocess.run(
92
+ plan.command,
93
+ shell=True,
94
+ capture_output=True,
95
+ text=True,
96
+ )
97
+ out = (proc.stdout or "") + (proc.stderr or "")
98
+ return proc.returncode, out or plan.dry_run_output
greedy_token/paths.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def find_monorepo_root(start: Path | None = None) -> Path:
8
+ env = os.environ.get("GREEDY_TOKEN_ROOT")
9
+ if env:
10
+ root = Path(env).expanduser().resolve()
11
+ if root.is_dir():
12
+ return root
13
+ raise SystemExit(f"GREEDY_TOKEN_ROOT is not a directory: {root}")
14
+
15
+ here = (start or Path(__file__)).resolve()
16
+ for parent in [here, *here.parents]:
17
+ if (parent / "docs" / "phase-manifest.json").is_file() and (
18
+ parent / "scripts" / "check-meta-sync.sh"
19
+ ).is_file():
20
+ return parent
21
+
22
+ raise SystemExit(
23
+ "Cannot find monorepo root. Set GREEDY_TOKEN_ROOT=/path/to/zero-design-system"
24
+ )
25
+
26
+
27
+ def load_routes_config() -> dict:
28
+ import yaml
29
+
30
+ config_path = Path(__file__).parent / "config" / "routes.yaml"
31
+ with config_path.open(encoding="utf-8") as f:
32
+ return yaml.safe_load(f)