cgh-codegen 0.1.0__tar.gz
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.
- cgh_codegen-0.1.0/PKG-INFO +91 -0
- cgh_codegen-0.1.0/README.md +82 -0
- cgh_codegen-0.1.0/cgh_codegen/__init__.py +24 -0
- cgh_codegen-0.1.0/cgh_codegen/backends.py +133 -0
- cgh_codegen-0.1.0/cgh_codegen/cli.py +295 -0
- cgh_codegen-0.1.0/cgh_codegen/flow.py +439 -0
- cgh_codegen-0.1.0/cgh_codegen/gate.py +73 -0
- cgh_codegen-0.1.0/cgh_codegen/generate.py +193 -0
- cgh_codegen-0.1.0/cgh_codegen/logview.py +42 -0
- cgh_codegen-0.1.0/cgh_codegen/mcp_tools.py +94 -0
- cgh_codegen-0.1.0/cgh_codegen/picker.py +238 -0
- cgh_codegen-0.1.0/cgh_codegen/py.typed +0 -0
- cgh_codegen-0.1.0/cgh_codegen.egg-info/PKG-INFO +91 -0
- cgh_codegen-0.1.0/cgh_codegen.egg-info/SOURCES.txt +26 -0
- cgh_codegen-0.1.0/cgh_codegen.egg-info/dependency_links.txt +1 -0
- cgh_codegen-0.1.0/cgh_codegen.egg-info/entry_points.txt +2 -0
- cgh_codegen-0.1.0/cgh_codegen.egg-info/top_level.txt +1 -0
- cgh_codegen-0.1.0/pyproject.toml +27 -0
- cgh_codegen-0.1.0/setup.cfg +4 -0
- cgh_codegen-0.1.0/tests/test_backends.py +87 -0
- cgh_codegen-0.1.0/tests/test_config_root.py +37 -0
- cgh_codegen-0.1.0/tests/test_flow.py +480 -0
- cgh_codegen-0.1.0/tests/test_gate.py +68 -0
- cgh_codegen-0.1.0/tests/test_generate.py +151 -0
- cgh_codegen-0.1.0/tests/test_logview.py +62 -0
- cgh_codegen-0.1.0/tests/test_picker.py +191 -0
- cgh_codegen-0.1.0/tests/test_prompt_clean.py +31 -0
- cgh_codegen-0.1.0/tests/test_spec_source.py +52 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cgh-codegen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pattern-matched code generation for cgh: a cheap model writes boilerplate that mirrors a reference file cgh picks from the graph
|
|
5
|
+
Author-email: Joy Ndjama <joy.ndjama@altikva.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# cgh-codegen
|
|
11
|
+
|
|
12
|
+
A cgh plugin that delegates predictable, pattern-following code (tests,
|
|
13
|
+
stubs, config, boilerplate) to a cheap model so the primary model spends
|
|
14
|
+
no tokens producing it. Its distinguishing move: cgh picks the reference
|
|
15
|
+
file to mirror straight from the code graph, so you do not have to name it.
|
|
16
|
+
|
|
17
|
+
Installs through cgh's plugin entry point. Inert without cgh.
|
|
18
|
+
|
|
19
|
+
## Surfaces
|
|
20
|
+
|
|
21
|
+
### `cgh codegen pick`
|
|
22
|
+
|
|
23
|
+
Report the existing file a generator should mirror for a target, and why.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
cgh codegen pick --target tests/test_user_service.py
|
|
27
|
+
# reference: tests/test_order_service.py
|
|
28
|
+
# defines a matching symbol, sibling in the same directory
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The pick combines two signals: the graph (a file defining a symbol
|
|
32
|
+
related to the target's name) and the filesystem (a sibling of the same
|
|
33
|
+
kind in the target's directory). It degrades to a filesystem-only pick
|
|
34
|
+
when the graph is not readable (no index yet, or an owner holds the write
|
|
35
|
+
lock). Pass `--reference` to validate a specific file instead.
|
|
36
|
+
|
|
37
|
+
### `cgh codegen gen`
|
|
38
|
+
|
|
39
|
+
Generate a file from a spec, mirroring the reference, and write it.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
cgh codegen gen --spec "pytest tests for UserService: create, update, delete" \
|
|
43
|
+
--target tests/test_user_service.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The reference (picked, or forced with `--reference`) is run through the
|
|
47
|
+
egress gate before it reaches a cloud model: a confidential or PII-labeled
|
|
48
|
+
reference is refused. A local backend skips the gate. An existing target is
|
|
49
|
+
never overwritten without `--force`; `--stdout` prints instead of writing.
|
|
50
|
+
|
|
51
|
+
`--extend` grows a file that already exists instead of writing a new one:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
cgh codegen gen --extend --target tests/test_user_service.py \
|
|
55
|
+
--spec "add a test for the soft-delete path" \
|
|
56
|
+
--verify "pytest tests/test_user_service.py -q"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The file itself goes to the model as the thing to add to, and the model
|
|
60
|
+
returns only the block to append, so nothing already in the file passes
|
|
61
|
+
through the model's output and nothing can be dropped from it. The block
|
|
62
|
+
lands before a trailing `if __name__ == "__main__":` guard rather than after
|
|
63
|
+
it. With `--verify`, a check that never passes restores the original: a
|
|
64
|
+
damaged existing file is worse than no change, which is the opposite of the
|
|
65
|
+
tradeoff for a new file, where the failed draft is left for you to read.
|
|
66
|
+
Anything the addition needs must already be imported in the file.
|
|
67
|
+
|
|
68
|
+
Configure the backend in `.codegraph/config.toml`:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[plugin.codegen]
|
|
72
|
+
command = "claude -p" # any agent CLI, invoked with the prompt on stdin
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The generated code is a proposal. Verify it by running the type-checker,
|
|
76
|
+
linter, or tests, never by trusting that it is correct because a later check
|
|
77
|
+
was green. This matters most for generated tests: a green run of tests you
|
|
78
|
+
did not read proves nothing.
|
|
79
|
+
|
|
80
|
+
### `codegen_pick` and `codegen_write` (MCP tools)
|
|
81
|
+
|
|
82
|
+
`codegen_pick(target, reference?)` returns the selection as JSON.
|
|
83
|
+
`codegen_write(spec, target, reference?, force?)` generates and writes the
|
|
84
|
+
file, returning what it wrote, the reference used, the egress decision, and
|
|
85
|
+
the cost. Both run inside the owner, so the graph read reuses its
|
|
86
|
+
connection.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT. Plugins that interact with cgh only through the documented plugin
|
|
91
|
+
interfaces are not derivative works of cgh.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# cgh-codegen
|
|
2
|
+
|
|
3
|
+
A cgh plugin that delegates predictable, pattern-following code (tests,
|
|
4
|
+
stubs, config, boilerplate) to a cheap model so the primary model spends
|
|
5
|
+
no tokens producing it. Its distinguishing move: cgh picks the reference
|
|
6
|
+
file to mirror straight from the code graph, so you do not have to name it.
|
|
7
|
+
|
|
8
|
+
Installs through cgh's plugin entry point. Inert without cgh.
|
|
9
|
+
|
|
10
|
+
## Surfaces
|
|
11
|
+
|
|
12
|
+
### `cgh codegen pick`
|
|
13
|
+
|
|
14
|
+
Report the existing file a generator should mirror for a target, and why.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
cgh codegen pick --target tests/test_user_service.py
|
|
18
|
+
# reference: tests/test_order_service.py
|
|
19
|
+
# defines a matching symbol, sibling in the same directory
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The pick combines two signals: the graph (a file defining a symbol
|
|
23
|
+
related to the target's name) and the filesystem (a sibling of the same
|
|
24
|
+
kind in the target's directory). It degrades to a filesystem-only pick
|
|
25
|
+
when the graph is not readable (no index yet, or an owner holds the write
|
|
26
|
+
lock). Pass `--reference` to validate a specific file instead.
|
|
27
|
+
|
|
28
|
+
### `cgh codegen gen`
|
|
29
|
+
|
|
30
|
+
Generate a file from a spec, mirroring the reference, and write it.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
cgh codegen gen --spec "pytest tests for UserService: create, update, delete" \
|
|
34
|
+
--target tests/test_user_service.py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The reference (picked, or forced with `--reference`) is run through the
|
|
38
|
+
egress gate before it reaches a cloud model: a confidential or PII-labeled
|
|
39
|
+
reference is refused. A local backend skips the gate. An existing target is
|
|
40
|
+
never overwritten without `--force`; `--stdout` prints instead of writing.
|
|
41
|
+
|
|
42
|
+
`--extend` grows a file that already exists instead of writing a new one:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
cgh codegen gen --extend --target tests/test_user_service.py \
|
|
46
|
+
--spec "add a test for the soft-delete path" \
|
|
47
|
+
--verify "pytest tests/test_user_service.py -q"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The file itself goes to the model as the thing to add to, and the model
|
|
51
|
+
returns only the block to append, so nothing already in the file passes
|
|
52
|
+
through the model's output and nothing can be dropped from it. The block
|
|
53
|
+
lands before a trailing `if __name__ == "__main__":` guard rather than after
|
|
54
|
+
it. With `--verify`, a check that never passes restores the original: a
|
|
55
|
+
damaged existing file is worse than no change, which is the opposite of the
|
|
56
|
+
tradeoff for a new file, where the failed draft is left for you to read.
|
|
57
|
+
Anything the addition needs must already be imported in the file.
|
|
58
|
+
|
|
59
|
+
Configure the backend in `.codegraph/config.toml`:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[plugin.codegen]
|
|
63
|
+
command = "claude -p" # any agent CLI, invoked with the prompt on stdin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The generated code is a proposal. Verify it by running the type-checker,
|
|
67
|
+
linter, or tests, never by trusting that it is correct because a later check
|
|
68
|
+
was green. This matters most for generated tests: a green run of tests you
|
|
69
|
+
did not read proves nothing.
|
|
70
|
+
|
|
71
|
+
### `codegen_pick` and `codegen_write` (MCP tools)
|
|
72
|
+
|
|
73
|
+
`codegen_pick(target, reference?)` returns the selection as JSON.
|
|
74
|
+
`codegen_write(spec, target, reference?, force?)` generates and writes the
|
|
75
|
+
file, returning what it wrote, the reference used, the egress decision, and
|
|
76
|
+
the cost. Both run inside the owner, so the graph read reuses its
|
|
77
|
+
connection.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT. Plugins that interact with cgh only through the documented plugin
|
|
82
|
+
interfaces are not derivative works of cgh.
|
|
@@ -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)))
|
|
@@ -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
|
+
)
|