ai-push-hooks 0.1.17 → 0.1.18

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.
package/README.md CHANGED
@@ -24,20 +24,31 @@ pnpm add -D ai-push-hooks
24
24
 
25
25
  Requirements:
26
26
 
27
- - [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required, including npm installs.
28
- - [OpenCode CLI](https://github.com/sst/opencode) is required for `llm` and `apply` steps. The expected executable is `opencode`; `opencode-cli` is also accepted for compatibility.
27
+ - [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required. The npm package is a wrapper around the Python CLI, so Python is still required when installed through npm/pnpm.
28
+ - [OpenCode](https://github.com/anomalyco/opencode) is required for `llm` and `apply` steps.
29
29
  - [GitHub CLI (`gh`)](https://cli.github.com/manual/installation) is required only if you use PR creation via `gh_pr_create`.
30
30
 
31
31
  ## Quick start
32
32
 
33
- 1. Install the CLI.
33
+ 1. Install by following the steps above.
34
34
  2. Generate a starter config:
35
35
 
36
+ Python tool install (`uv tool` / `pipx`):
37
+
36
38
  ```bash
37
39
  ai-push-hooks init --template minimal-docs
38
40
  ```
39
41
 
40
- 3. Wire it into Lefthook:
42
+ npm/pnpm local install:
43
+
44
+ ```bash
45
+ npx ai-push-hooks init --template minimal-docs
46
+ # or
47
+ pnpm exec ai-push-hooks init --template minimal-docs
48
+ ```
49
+
50
+ 3. Configure modules and steps in [Configuration reference](#configuration-reference).
51
+ 4. Wire it into your pre-push hook manager. Lefthook example:
41
52
 
42
53
  ```yaml
43
54
  pre-push:
@@ -46,10 +57,14 @@ Requirements:
46
57
  run: ai-push-hooks hook {1} {2}
47
58
  ```
48
59
 
49
- 4. Push as usual. The workflow runs automatically before push completes.
60
+ In Lefthook, `{1}` is the remote name and `{2}` is the remote URL from Git's `pre-push` hook args.
61
+
62
+ 5. Push as usual. The workflow runs automatically before push completes.
50
63
 
51
64
  ## Commands
52
65
 
66
+ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm exec`.
67
+
53
68
  | Command | What it does |
54
69
  | --- | --- |
55
70
  | `ai-push-hooks hook <remote-name> <remote-url>` | Runs the configured pre-push workflow. |
@@ -58,9 +73,7 @@ Requirements:
58
73
 
59
74
  ## Configuration overview
60
75
 
61
- - Config file lookup order: `ai-push-hooks.toml`, then `.ai-push-hooks.toml` (legacy).
62
- - If no config file is present, built-in defaults are used.
63
- - File values are deep-merged over defaults.
76
+ - Config file: `ai-push-hooks.toml` in repo root (required).
64
77
  - Prompt resolution precedence for `llm` and `apply` steps:
65
78
  1. `prompt`
66
79
  2. `prompt_file`
@@ -72,11 +85,11 @@ Requirements:
72
85
 
73
86
  | Key | Type | Required | Default |
74
87
  | --- | --- | --- | --- |
75
- | `general` | table | no | built-in values |
76
- | `llm` | table | no | built-in values |
77
- | `logging` | table | no | built-in values |
78
- | `workflow` | table | yes | `{ modules = ["docs"] }` |
79
- | `modules` | table | yes | `{ docs = ... }` |
88
+ | `general` | table | no | see section defaults |
89
+ | `llm` | table | no | see section defaults |
90
+ | `logging` | table | no | see section defaults |
91
+ | `workflow` | table | yes | n/a |
92
+ | `modules` | table | yes | n/a |
80
93
 
81
94
  ### `[general]`
82
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Modular AI push-hook workflow runner",
5
5
  "license": "MIT",
6
6
  "repository": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.1.17"
7
+ version = "0.1.18"
8
8
  description = "Modular AI push-hook workflow runner"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,10 +28,8 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
28
28
  raise HookError("Only `minimal-docs` is supported")
29
29
  target_dir = cwd or pathlib.Path.cwd()
30
30
  config_path = target_dir / "ai-push-hooks.toml"
31
- legacy_path = target_dir / ".ai-push-hooks.toml"
32
- existing_path = config_path if config_path.exists() else legacy_path if legacy_path.exists() else None
33
- if existing_path is not None and not force:
34
- raise HookError(f"Refusing to overwrite existing config without --force: {existing_path}")
31
+ if config_path.exists() and not force:
32
+ raise HookError(f"Refusing to overwrite existing config without --force: {config_path}")
35
33
  config_path.write_text(MINIMAL_DOCS_TEMPLATE, encoding="utf-8")
36
34
  sys.stdout.write(str(config_path) + "\n")
37
35
  return 0
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import copy
4
3
  import json
5
4
  import os
6
5
  import pathlib
@@ -16,80 +15,6 @@ try:
16
15
  except ModuleNotFoundError: # pragma: no cover
17
16
  tomllib = None # type: ignore[assignment]
18
17
 
19
- DEFAULT_CONFIG_RAW: dict[str, Any] = {
20
- "general": {
21
- "enabled": True,
22
- "allow_push_on_error": False,
23
- "require_clean_worktree": False,
24
- "skip_on_sync_branch": True,
25
- },
26
- "llm": {
27
- "runner": "opencode",
28
- "model": "openai/gpt-5.3-codex",
29
- "variant": "",
30
- "timeout_seconds": 800,
31
- "max_parallel": 2,
32
- "json_max_retries": 2,
33
- "invalid_json_feedback_max_chars": 6000,
34
- "json_retry_new_session": True,
35
- "delete_session_after_run": True,
36
- "max_diff_bytes": 180000,
37
- "session_title_prefix": "ai-push-hooks",
38
- },
39
- "logging": {
40
- "level": "status",
41
- "jsonl": True,
42
- "dir": ".git/ai-push-hooks/logs",
43
- "capture_llm_transcript": True,
44
- "transcript_dir": ".git/ai-push-hooks/transcripts",
45
- "summary_dir": ".git/ai-push-hooks/summaries",
46
- "print_llm_output": False,
47
- },
48
- "workflow": {"modules": ["docs"]},
49
- "modules": {
50
- "docs": {
51
- "enabled": True,
52
- "steps": [
53
- {"id": "collect", "type": "collect", "collector": "docs_context"},
54
- {
55
- "id": "query",
56
- "type": "llm",
57
- "inputs": ["collect/push.diff", "collect/changed-files.txt"],
58
- "output": "queries.json",
59
- "schema": "string_array",
60
- "fallback_prompt_id": "docs-query-basic",
61
- },
62
- {
63
- "id": "analyze",
64
- "type": "llm",
65
- "inputs": [
66
- "collect/push.diff",
67
- "collect/docs-context.txt",
68
- "query/queries.json",
69
- "collect/recent-commits.txt",
70
- ],
71
- "output": "issues.json",
72
- "schema": "docs_issue_array",
73
- "fallback_prompt_id": "docs-analysis-basic",
74
- },
75
- {
76
- "id": "apply",
77
- "type": "apply",
78
- "inputs": ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"],
79
- "allow_paths": ["README.md", "docs/**/*.md"],
80
- "fallback_prompt_id": "docs-apply-basic",
81
- },
82
- {
83
- "id": "assert",
84
- "type": "assert",
85
- "inputs": ["apply/result.json"],
86
- "assertion": "docs_apply_requires_manual_commit",
87
- },
88
- ]
89
- }
90
- },
91
- }
92
-
93
18
  ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
94
19
 
95
20
 
@@ -174,16 +99,6 @@ def parse_toml_fallback(raw: str) -> dict[str, Any]:
174
99
  return parsed
175
100
 
176
101
 
177
- def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
178
- merged = copy.deepcopy(base)
179
- for key, value in override.items():
180
- if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
181
- merged[key] = deep_merge(merged[key], value)
182
- else:
183
- merged[key] = copy.deepcopy(value)
184
- return merged
185
-
186
-
187
102
  def _normalize_step(raw: dict[str, Any]) -> StepConfig:
188
103
  step_type = str(raw.get("type", "")).strip()
189
104
  if step_type not in SUPPORTED_STEP_TYPES:
@@ -315,19 +230,18 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
315
230
  return _build_config(raw)
316
231
 
317
232
 
318
- def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path | None]:
319
- config_path: pathlib.Path | None = None
320
- raw = copy.deepcopy(DEFAULT_CONFIG_RAW)
321
- for candidate in [repo_root / "ai-push-hooks.toml", repo_root / ".ai-push-hooks.toml"]:
322
- if candidate.exists():
323
- config_path = candidate
324
- text = candidate.read_text(encoding="utf-8")
325
- loaded = tomllib.loads(text) if tomllib is not None else parse_toml_fallback(text)
326
- if not isinstance(loaded, dict):
327
- raise HookError(f"Invalid config format in {candidate}")
328
- raw = deep_merge(raw, loaded)
329
- break
330
- return _apply_env_overrides(_build_config(raw)), config_path
233
+ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
234
+ config_path = repo_root / "ai-push-hooks.toml"
235
+ if not config_path.exists():
236
+ raise HookError(
237
+ "Missing required config file `ai-push-hooks.toml` in repo root. "
238
+ "Run `ai-push-hooks init --template minimal-docs` first"
239
+ )
240
+ text = config_path.read_text(encoding="utf-8")
241
+ loaded = tomllib.loads(text) if tomllib is not None else parse_toml_fallback(text)
242
+ if not isinstance(loaded, dict):
243
+ raise HookError(f"Invalid config format in {config_path}")
244
+ return _apply_env_overrides(_build_config(loaded)), config_path
331
245
 
332
246
 
333
247
  def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
@@ -32,7 +32,7 @@ def resolve_opencode_executable() -> str:
32
32
  cli_path = shutil.which("opencode-cli")
33
33
  if cli_path:
34
34
  return cli_path
35
- raise HookError("opencode (or opencode-cli) is required but not installed")
35
+ raise HookError("opencode is required but not installed")
36
36
 
37
37
 
38
38
  def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]: