cgh-codegen 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.
@@ -0,0 +1,24 @@
1
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
2
+ # __creation__ = 2026-09-14
3
+ # __author__ = "jndjama (Joy Ndjama)"
4
+ # __copyright__ = "Copyright 2026 ALTIKVA."
5
+ # __licence__ = "MIT"
6
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
7
+ # Description: cgh plugin entry point. Registers the `cgh codegen` CLI
8
+ # verbs (pick, gen) and the codegen_pick / codegen_write MCP
9
+ # tools: pick the file to mirror, then generate the target from
10
+ # a spec with a cheap model, behind the egress gate.
11
+
12
+ from __future__ import annotations
13
+
14
+ CGH_PLUGIN_API = 1
15
+
16
+
17
+ def register(api) -> None:
18
+ from .cli import make_cli_registrar
19
+
20
+ api.register_cli(make_cli_registrar(api.config))
21
+
22
+ from .mcp_tools import make_mcp_registrar
23
+
24
+ api.register_mcp_tools(make_mcp_registrar(api.config))
@@ -0,0 +1,133 @@
1
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
2
+ # __creation__ = 2026-09-15
3
+ # __author__ = "jndjama (Joy Ndjama)"
4
+ # __copyright__ = "Copyright 2026 ALTIKVA."
5
+ # __licence__ = "MIT"
6
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
7
+ # Description: Two backends. CliBackend shells out to a configured agent CLI
8
+ # (claude, codex, ...), a cloud egress the flow gates. OllamaBackend
9
+ # calls a local Ollama model, so nothing leaves the machine and the
10
+ # gate is skipped, the leanest, free path for boilerplate.
11
+ # resolve_backend picks between them from config. The four-backend
12
+ # matrix from cgh-summarize is not rebuilt; more come with demand.
13
+ # Every call goes through the Backend protocol, so tests use a fake.
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ import urllib.error
21
+ import urllib.request
22
+
23
+ _OLLAMA_DEFAULT_URL = "http://127.0.0.1:11434/api/generate"
24
+
25
+
26
+ class CliBackend:
27
+ """Generate by invoking a configured agent CLI. The agent reaches a
28
+ cloud model, so this is NOT local: the flow runs each reference through
29
+ the egress gate before calling it."""
30
+
31
+ is_local = False
32
+
33
+ def __init__(self, command: list[str], timeout: float = 120.0) -> None:
34
+ # command is an argv list from config, e.g. ["claude", "-p"]. Never a
35
+ # shell string: no shell=True, so nothing in the prompt is interpreted.
36
+ self._command = list(command)
37
+ self._timeout = timeout
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ return f"cli:{self._command[0]}" if self._command else "cli"
42
+
43
+ def available(self) -> bool:
44
+ return bool(self._command) and shutil.which(self._command[0]) is not None
45
+
46
+ def generate(self, system: str, user: str) -> tuple[str, float]:
47
+ from codegraph.plugin_api import quiet_subprocess_kwargs
48
+
49
+ prompt = f"{system}\n\n{user}"
50
+ try:
51
+ proc = subprocess.run(
52
+ [*self._command, "--"],
53
+ input=prompt,
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=self._timeout,
57
+ check=False,
58
+ **quiet_subprocess_kwargs(),
59
+ )
60
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
61
+ return "", 0.0
62
+ # Cost is unknown for an agent CLI (it bills its own account); report
63
+ # 0.0 rather than invent a number.
64
+ return proc.stdout or "", 0.0
65
+
66
+
67
+ class OllamaBackend:
68
+ """Generate with a local Ollama model. Nothing leaves the machine, so
69
+ is_local is True and the flow skips the egress gate. The leanest option:
70
+ no cloud round-trip, no per-token cost."""
71
+
72
+ is_local = True
73
+
74
+ def __init__(
75
+ self, model: str, url: str = _OLLAMA_DEFAULT_URL, timeout: float = 120.0
76
+ ) -> None:
77
+ self._model = model
78
+ self._url = url
79
+ self._timeout = timeout
80
+
81
+ @property
82
+ def name(self) -> str:
83
+ return f"ollama:{self._model}"
84
+
85
+ def available(self) -> bool:
86
+ return bool(self._model)
87
+
88
+ def generate(self, system: str, user: str) -> tuple[str, float]:
89
+ payload = json.dumps(
90
+ {
91
+ "model": self._model,
92
+ "prompt": f"{system}\n\n{user}",
93
+ "stream": False,
94
+ }
95
+ ).encode("utf-8")
96
+ req = urllib.request.Request(
97
+ self._url,
98
+ data=payload,
99
+ headers={"Content-Type": "application/json"},
100
+ method="POST",
101
+ )
102
+ try:
103
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
104
+ body = json.loads(resp.read().decode("utf-8"))
105
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError):
106
+ return "", 0.0
107
+ # Local generation is free; report 0.0 cost.
108
+ return body.get("response", ""), 0.0
109
+
110
+
111
+ def resolve_backend(config: dict):
112
+ """The backend to generate with, chosen from config. An explicit
113
+ ``backend = "ollama"`` (or an ``ollama_model`` with no ``command``) selects
114
+ the local model; otherwise a ``command`` selects the agent CLI. Returns
115
+ None when nothing is configured, so the caller reports a clear "no backend"
116
+ instead of guessing."""
117
+ kind = str(config.get("backend", "")).strip().lower()
118
+ if kind == "ollama" or (not config.get("command") and config.get("ollama_model")):
119
+ model = config.get("ollama_model")
120
+ if not model:
121
+ return None
122
+ return OllamaBackend(
123
+ model,
124
+ config.get("ollama_url", _OLLAMA_DEFAULT_URL),
125
+ timeout=float(config.get("timeout", 120.0)),
126
+ )
127
+
128
+ command = config.get("command")
129
+ if isinstance(command, str):
130
+ command = command.split()
131
+ if not command:
132
+ return None
133
+ return CliBackend(command, timeout=float(config.get("timeout", 120.0)))
cgh_codegen/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
2
+ # __creation__ = 2026-09-14
3
+ # __author__ = "jndjama (Joy Ndjama)"
4
+ # __copyright__ = "Copyright 2026 ALTIKVA."
5
+ # __licence__ = "MIT"
6
+ # -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
7
+ # Description: CLI verbs `cgh codegen pick` (report the reference to mirror),
8
+ # `cgh codegen gen` (generate the file from a spec plus that
9
+ # reference, behind the egress gate, refusing to clobber without
10
+ # --force), and `cgh codegen log` (show what codegen has done in
11
+ # this repo, read from the activity log, at no model-token cost).
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+
19
+ def plugin_config_for_root(root: str | Path, fallback: dict) -> dict:
20
+ """The [plugin.codegen] table resolved from ``root``.
21
+
22
+ Plugins are loaded once at CLI startup against the current directory, so
23
+ the config captured then belongs to the CWD, not to a --root passed on
24
+ the command line. Re-resolve from the requested root so --root governs
25
+ the backend and the egress posture too, not just where the file lands.
26
+ Falls back to the captured config when the root declares no table.
27
+ """
28
+ from codegraph.plugin_api import load_config
29
+
30
+ return load_config(root).plugin_tables.get("codegen", fallback)
31
+
32
+
33
+ def make_cli_registrar(config: dict):
34
+ def add_cli(sub) -> None:
35
+ p = sub.add_parser("codegen", help="Pattern-matched code generation helpers")
36
+ actions = p.add_subparsers(dest="cw_action")
37
+
38
+ pick = actions.add_parser(
39
+ "pick", help="Show the reference file to mirror for a target"
40
+ )
41
+ pick.add_argument("--target", required=True, help="File you intend to write")
42
+ pick.add_argument(
43
+ "--reference", default="", help="Force a reference instead of picking one"
44
+ )
45
+ pick.add_argument("--root", default=os.getcwd())
46
+ pick.set_defaults(func=lambda args: _cmd_pick(args, config))
47
+
48
+ gen = actions.add_parser(
49
+ "gen", help="Generate a file from a spec, mirroring a reference"
50
+ )
51
+ gen.add_argument(
52
+ "--spec",
53
+ default="",
54
+ help="What to generate. Use '-' to read the spec from stdin. "
55
+ "Single-quote it in the shell, or use --spec-file, to avoid the "
56
+ "shell running backticks or $(...) inside it.",
57
+ )
58
+ gen.add_argument(
59
+ "--spec-file",
60
+ default="",
61
+ help="Read the spec from this file ('-' for stdin), instead of "
62
+ "--spec. Sidesteps shell quoting for long or backtick-heavy specs.",
63
+ )
64
+ gen.add_argument("--target", required=True, help="File to write")
65
+ gen.add_argument(
66
+ "--reference", default="", help="Force a reference instead of picking one"
67
+ )
68
+ gen.add_argument(
69
+ "--force", action="store_true", help="Overwrite the target if it exists"
70
+ )
71
+ gen.add_argument(
72
+ "--stdout", action="store_true", help="Print the code, do not write a file"
73
+ )
74
+ gen.add_argument(
75
+ "--extend",
76
+ action="store_true",
77
+ help="Add to an existing target instead of writing a new file; "
78
+ "the model returns only the block to append",
79
+ )
80
+ gen.add_argument(
81
+ "--verify",
82
+ default="",
83
+ help="Shell check (exit 0 = pass) run after writing; drives the "
84
+ "self-correct loop",
85
+ )
86
+ gen.add_argument(
87
+ "--max-attempts",
88
+ type=int,
89
+ default=1,
90
+ help="With --verify, regenerate up to N times feeding the failure back",
91
+ )
92
+ gen.add_argument("--root", default=os.getcwd())
93
+ gen.set_defaults(func=lambda args: _cmd_gen(args, config))
94
+
95
+ log = actions.add_parser(
96
+ "log",
97
+ help="Show what codegen has done here, read from the activity log "
98
+ "(no model tokens)",
99
+ )
100
+ log.add_argument("--limit", type=int, default=20, help="Max entries to show")
101
+ log.add_argument("--root", default=os.getcwd())
102
+ log.set_defaults(func=lambda args: _cmd_log(args, config))
103
+
104
+ return add_cli
105
+
106
+
107
+ def _cmd_log(args, config: dict) -> None:
108
+ from datetime import datetime
109
+
110
+ from rich.console import Console
111
+ from rich.table import Table
112
+
113
+ from .logview import codegen_activity
114
+
115
+ console = Console()
116
+ root = Path(os.path.abspath(args.root))
117
+ rows = codegen_activity(root, limit=args.limit)
118
+ if not rows:
119
+ console.print(
120
+ "[dim]No codegen activity logged in this repo yet. "
121
+ "Runs of cgh codegen gen show up here.[/dim]"
122
+ )
123
+ return
124
+
125
+ table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
126
+ table.add_column("when", style="dim", no_wrap=True)
127
+ table.add_column("event")
128
+ table.add_column("detail", overflow="fold")
129
+ for ts, event, detail in rows:
130
+ try:
131
+ when = datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
132
+ except (ValueError, OSError):
133
+ when = "?"
134
+ kind = event.removeprefix("codegen_")
135
+ label = f"[red]{kind}[/red]" if kind == "egress_denied" else kind
136
+ table.add_row(when, label, detail)
137
+ console.print(f"[bold]codegen activity[/bold] [dim]({len(rows)})[/dim]")
138
+ console.print(table)
139
+ console.print(
140
+ "[dim]Read straight from .codegraph/activity.log; "
141
+ "nothing here entered a model's context.[/dim]"
142
+ )
143
+
144
+
145
+ def _cmd_pick(args, config: dict) -> None:
146
+ from rich.console import Console
147
+
148
+ from .picker import CodegenError, pick_reference
149
+
150
+ console = Console()
151
+ root = Path(os.path.abspath(args.root))
152
+ try:
153
+ result = pick_reference(root, args.target, args.reference or None)
154
+ except CodegenError as exc:
155
+ console.print(f"[red]{exc}[/red]")
156
+ raise SystemExit(1) from exc
157
+
158
+ if result["reference"] is None:
159
+ console.print(f"[yellow]{result['reason']}[/yellow]")
160
+ if result["graph_available"] is False:
161
+ console.print(
162
+ "[dim]graph unavailable (no index, or an owner holds the "
163
+ "lock); picked from the filesystem only.[/dim]"
164
+ )
165
+ return
166
+
167
+ console.print(f"[bold]reference:[/bold] {result['reference']}")
168
+ console.print(f"[dim]{result['reason']}[/dim]")
169
+ others = [c for c in result["candidates"] if c != result["reference"]]
170
+ if others:
171
+ console.print("[dim]other candidates: " + ", ".join(others) + "[/dim]")
172
+ if result["graph_available"] is False:
173
+ console.print("[dim]graph unavailable; filesystem-only pick.[/dim]")
174
+
175
+
176
+ def _resolve_spec(args, console) -> str:
177
+ """The spec text, from --spec, --spec-file, or stdin ('-' in either).
178
+
179
+ Reading from a file or stdin lets an agent pass a long or backtick-heavy
180
+ spec without the shell running command-substitution inside it (the reported
181
+ footgun of an unquoted --spec). Exits with a clear message on an empty or
182
+ missing spec.
183
+ """
184
+ import sys
185
+
186
+ spec_file = getattr(args, "spec_file", "") or ""
187
+ spec = getattr(args, "spec", "") or ""
188
+ text: str | None = None
189
+ if spec_file == "-" or spec == "-":
190
+ text = sys.stdin.read()
191
+ elif spec_file:
192
+ try:
193
+ text = Path(spec_file).read_text(encoding="utf-8")
194
+ except OSError as exc:
195
+ console.print(f"[red]cannot read --spec-file {spec_file}: {exc}[/red]")
196
+ raise SystemExit(1) from exc
197
+ elif spec:
198
+ text = spec
199
+ text = (text or "").strip()
200
+ if not text:
201
+ console.print(
202
+ "[red]empty spec.[/red] Pass --spec '...' (single-quoted), "
203
+ "--spec-file <path>, or --spec - to read from stdin."
204
+ )
205
+ raise SystemExit(1)
206
+ return text
207
+
208
+
209
+ def _cmd_gen(args, config: dict) -> None:
210
+ from rich.console import Console
211
+
212
+ from .backends import resolve_backend
213
+ from .flow import run_generation
214
+ from .generate import GenerationError
215
+ from .picker import CodegenError
216
+
217
+ console = Console()
218
+ root = Path(os.path.abspath(args.root))
219
+ config = plugin_config_for_root(root, config)
220
+
221
+ spec = _resolve_spec(args, console)
222
+
223
+ backend = resolve_backend(config)
224
+ if backend is None:
225
+ console.print(
226
+ "[red]no backend configured.[/red] Set a backend command under "
227
+ "plugin.codegen in .codegraph/config.toml "
228
+ '(command = "claude -p").'
229
+ )
230
+ raise SystemExit(1)
231
+
232
+ try:
233
+ result = run_generation(
234
+ root,
235
+ spec,
236
+ args.target,
237
+ args.reference or None,
238
+ config=config,
239
+ backend=backend,
240
+ force=args.force,
241
+ to_stdout=args.stdout,
242
+ verify=args.verify or None,
243
+ max_attempts=max(1, args.max_attempts),
244
+ extend=args.extend,
245
+ )
246
+ except (CodegenError, GenerationError) as exc:
247
+ console.print(f"[red]{exc}[/red]")
248
+ raise SystemExit(1) from exc
249
+
250
+ if args.stdout:
251
+ # code to stdout stays clean; the notes go to stderr via rich stderr
252
+ stderr = Console(stderr=True)
253
+ if result.get("ref_fallback"):
254
+ stderr.print(f"[yellow]note:[/yellow] [dim]{result['ref_fallback']}[/dim]")
255
+ stderr.print(
256
+ f"[dim]{result['reference']} -> {result['lines']} lines "
257
+ f"({result['backend']}, egress: {result['egress']})[/dim]"
258
+ )
259
+ print(result["code"])
260
+ return
261
+
262
+ if result.get("rolled_back"):
263
+ console.print(
264
+ f"[red]not appended[/red] to {result['target']}: the check never "
265
+ f"passed in {result['attempts']} attempt(s), so the file was left "
266
+ "as it was."
267
+ )
268
+ raise SystemExit(1)
269
+ if result.get("ref_fallback"):
270
+ console.print(f"[yellow]note:[/yellow] [dim]{result['ref_fallback']}[/dim]")
271
+ if result["extended"]:
272
+ console.print(
273
+ f"[green]appended to[/green] {result['target']} "
274
+ f"[dim]({result['lines']} lines added)[/dim]"
275
+ )
276
+ else:
277
+ console.print(
278
+ f"[green]wrote[/green] {result['target']} "
279
+ f"[dim]({result['lines']} lines, mirror of {result['reference']})[/dim]"
280
+ )
281
+ v = result.get("verified")
282
+ if v is True:
283
+ console.print(
284
+ f"[green]check passed[/green] "
285
+ f"[dim]after {result['attempts']} attempt(s)[/dim]"
286
+ )
287
+ elif v is False:
288
+ console.print(
289
+ f"[yellow]check still failing[/yellow] after {result['attempts']} "
290
+ "attempt(s); the written file is the last try, review it."
291
+ )
292
+ console.print(
293
+ f"[dim]backend: {result['backend']} egress: {result['egress']} "
294
+ "verify by running the checks, not by trusting this output.[/dim]"
295
+ )