multi-forge 0.2.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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Hook enable/disable for Claude Code settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from forge.install.preset import get_builtin_preset
|
|
13
|
+
from forge.session.claude.paths import get_claude_home
|
|
14
|
+
|
|
15
|
+
SETTINGS_FILENAME = "settings.local.json"
|
|
16
|
+
|
|
17
|
+
# Single source of truth: derive from the canonical preset
|
|
18
|
+
FORGE_HOOK_CONFIG: dict[str, Any] = {"hooks": get_builtin_preset()["hooks"]}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_hooks_target(scope: str | None) -> tuple[Path, str]:
|
|
22
|
+
"""Find target settings file for hooks based on scope.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
scope: "user", "local", or None (auto-detect)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Tuple of (settings_file_path, display_location)
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
click.ClickException if no valid target found
|
|
32
|
+
"""
|
|
33
|
+
if scope == "user":
|
|
34
|
+
settings_dir = get_claude_home()
|
|
35
|
+
return settings_dir / SETTINGS_FILENAME, "~/.claude"
|
|
36
|
+
|
|
37
|
+
if scope == "local":
|
|
38
|
+
settings_dir = Path.cwd() / ".claude"
|
|
39
|
+
return settings_dir / SETTINGS_FILENAME, ".claude"
|
|
40
|
+
|
|
41
|
+
current = Path.cwd().resolve()
|
|
42
|
+
home = Path.home().resolve()
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
claude_dir = current / ".claude"
|
|
46
|
+
if claude_dir.is_dir():
|
|
47
|
+
if current == home:
|
|
48
|
+
return claude_dir / SETTINGS_FILENAME, "~/.claude"
|
|
49
|
+
# Use relpath to safely compute display path (works when .claude is above cwd)
|
|
50
|
+
display_path = os.path.relpath(claude_dir, Path.cwd())
|
|
51
|
+
return claude_dir / SETTINGS_FILENAME, display_path
|
|
52
|
+
|
|
53
|
+
if current == home:
|
|
54
|
+
# At home without finding .claude = use user scope
|
|
55
|
+
return (get_claude_home() / SETTINGS_FILENAME), "~/.claude"
|
|
56
|
+
|
|
57
|
+
parent = current.parent
|
|
58
|
+
if parent == current:
|
|
59
|
+
raise click.ClickException(
|
|
60
|
+
"No .claude directory found. " "Run from a Claude Code project, or use --user for global install."
|
|
61
|
+
)
|
|
62
|
+
current = parent
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command(name="enable")
|
|
66
|
+
@click.option(
|
|
67
|
+
"--user",
|
|
68
|
+
"-U",
|
|
69
|
+
"scope",
|
|
70
|
+
flag_value="user",
|
|
71
|
+
help="Enable for ~/.claude/settings.local.json",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--local",
|
|
75
|
+
"-L",
|
|
76
|
+
"scope",
|
|
77
|
+
flag_value="local",
|
|
78
|
+
help="Enable for .claude/settings.local.json (current directory)",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--force",
|
|
82
|
+
"-f",
|
|
83
|
+
is_flag=True,
|
|
84
|
+
help="Overwrite existing hook configuration",
|
|
85
|
+
)
|
|
86
|
+
def enable(scope: str | None, force: bool) -> None:
|
|
87
|
+
"""Enable Forge hooks in Claude Code settings.
|
|
88
|
+
|
|
89
|
+
Adds all Forge hook configurations to settings.local.json.
|
|
90
|
+
|
|
91
|
+
\b
|
|
92
|
+
Scope Detection (when no --user/--local specified):
|
|
93
|
+
Walks up from current directory looking for a .claude/ directory.
|
|
94
|
+
- If found: enables in that project's .claude/settings.local.json
|
|
95
|
+
- If reached ~: enables in ~/.claude/settings.local.json
|
|
96
|
+
"""
|
|
97
|
+
from forge.install.version import check_minimum_version
|
|
98
|
+
|
|
99
|
+
version_check = check_minimum_version()
|
|
100
|
+
if not version_check.ok:
|
|
101
|
+
click.echo(f"Error: {version_check.reason}", err=True)
|
|
102
|
+
click.echo("Tip: Run 'claude update' to upgrade.", err=True)
|
|
103
|
+
raise SystemExit(1)
|
|
104
|
+
|
|
105
|
+
settings_file, location = _find_hooks_target(scope)
|
|
106
|
+
if scope is None:
|
|
107
|
+
click.echo(f"Auto-detected: {location}")
|
|
108
|
+
|
|
109
|
+
settings: dict[str, Any] = {}
|
|
110
|
+
if settings_file.exists():
|
|
111
|
+
try:
|
|
112
|
+
settings = json.loads(settings_file.read_text(encoding="utf-8"))
|
|
113
|
+
except json.JSONDecodeError as e:
|
|
114
|
+
click.echo(f"Error: Invalid JSON in {settings_file}: {e}", err=True)
|
|
115
|
+
raise SystemExit(1)
|
|
116
|
+
|
|
117
|
+
existing_hooks = settings.get("hooks", {})
|
|
118
|
+
if any(key in existing_hooks for key in FORGE_HOOK_CONFIG["hooks"].keys()) and not force:
|
|
119
|
+
click.echo(f"Forge hooks already configured in {settings_file}")
|
|
120
|
+
click.echo("Tip: Use --force to overwrite")
|
|
121
|
+
raise SystemExit(1)
|
|
122
|
+
|
|
123
|
+
if "hooks" not in settings:
|
|
124
|
+
settings["hooks"] = {}
|
|
125
|
+
|
|
126
|
+
for key, value in FORGE_HOOK_CONFIG["hooks"].items():
|
|
127
|
+
settings["hooks"][key] = value
|
|
128
|
+
|
|
129
|
+
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
click.echo(f"Enabled Forge hooks in {location}/{SETTINGS_FILENAME}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_forge_hook_entry(entry: Any) -> bool:
|
|
136
|
+
"""Check if a hook entry is a Forge hook.
|
|
137
|
+
|
|
138
|
+
Matches entries where:
|
|
139
|
+
- type == "command" AND command starts with "forge hook "
|
|
140
|
+
- OR nested hooks contain such an entry
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(entry, dict):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Direct command format: {type: "command", command: "forge hook ..."}
|
|
146
|
+
if entry.get("type") == "command":
|
|
147
|
+
cmd = entry.get("command", "")
|
|
148
|
+
if isinstance(cmd, str) and cmd.strip().startswith("forge hook "):
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
# Nested hooks format: {hooks: [{type: "command", command: "forge hook ..."}]}
|
|
152
|
+
hooks_list = entry.get("hooks")
|
|
153
|
+
if isinstance(hooks_list, list):
|
|
154
|
+
for h in hooks_list:
|
|
155
|
+
if isinstance(h, dict) and h.get("type") == "command":
|
|
156
|
+
nested_cmd = h.get("command", "")
|
|
157
|
+
if isinstance(nested_cmd, str) and nested_cmd.strip().startswith("forge hook "):
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@click.command(name="disable")
|
|
164
|
+
@click.option(
|
|
165
|
+
"--user",
|
|
166
|
+
"-U",
|
|
167
|
+
"scope",
|
|
168
|
+
flag_value="user",
|
|
169
|
+
help="Disable from ~/.claude/settings.local.json",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--local",
|
|
173
|
+
"-L",
|
|
174
|
+
"scope",
|
|
175
|
+
flag_value="local",
|
|
176
|
+
help="Disable from .claude/settings.local.json (current directory)",
|
|
177
|
+
)
|
|
178
|
+
def disable(scope: str | None) -> None:
|
|
179
|
+
"""Disable Forge hooks in Claude Code settings.
|
|
180
|
+
|
|
181
|
+
Removes all Forge hook configuration entries from settings.local.json.
|
|
182
|
+
|
|
183
|
+
\b
|
|
184
|
+
Scope Detection (when no --user/--local specified):
|
|
185
|
+
Walks up from current directory looking for a .claude/ directory.
|
|
186
|
+
- If found: disables in that project's .claude/settings.local.json
|
|
187
|
+
- If reached ~: disables in ~/.claude/settings.local.json
|
|
188
|
+
"""
|
|
189
|
+
settings_file, location = _find_hooks_target(scope)
|
|
190
|
+
if scope is None:
|
|
191
|
+
click.echo(f"Auto-detected: {location}")
|
|
192
|
+
|
|
193
|
+
if not settings_file.exists():
|
|
194
|
+
click.echo(f"No settings file found at {settings_file}")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
settings = json.loads(settings_file.read_text(encoding="utf-8"))
|
|
199
|
+
except json.JSONDecodeError as e:
|
|
200
|
+
click.echo(f"Error: Invalid JSON in {settings_file}: {e}", err=True)
|
|
201
|
+
raise SystemExit(1)
|
|
202
|
+
|
|
203
|
+
hooks_config = settings.get("hooks", {})
|
|
204
|
+
if not hooks_config:
|
|
205
|
+
click.echo("No hooks configured")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
removed_any = False
|
|
209
|
+
for hook_name in list(hooks_config.keys()):
|
|
210
|
+
existing = hooks_config.get(hook_name, [])
|
|
211
|
+
if not isinstance(existing, list):
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
remaining = [e for e in existing if not _is_forge_hook_entry(e)]
|
|
215
|
+
if len(remaining) != len(existing):
|
|
216
|
+
removed_any = True
|
|
217
|
+
|
|
218
|
+
if remaining:
|
|
219
|
+
settings["hooks"][hook_name] = remaining
|
|
220
|
+
else:
|
|
221
|
+
del settings["hooks"][hook_name]
|
|
222
|
+
|
|
223
|
+
if not removed_any:
|
|
224
|
+
click.echo("No Forge hooks found to disable")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if not settings.get("hooks"):
|
|
228
|
+
settings.pop("hooks", None)
|
|
229
|
+
|
|
230
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
231
|
+
|
|
232
|
+
click.echo(f"Disabled Forge hooks in {location}/{SETTINGS_FILENAME}")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Policy check helpers for the PreToolUse policy-check hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from forge.core.state import now_iso
|
|
9
|
+
from forge.session import SessionStore
|
|
10
|
+
from forge.session.models import SessionState
|
|
11
|
+
from forge.session.store import HOOK_LOCK_TIMEOUT_S
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_action_context(
|
|
15
|
+
data: dict[str, Any],
|
|
16
|
+
tool_name: str,
|
|
17
|
+
manifest: Any,
|
|
18
|
+
) -> Any | None:
|
|
19
|
+
"""Build ActionContext from hook payload.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: Hook JSON payload
|
|
23
|
+
tool_name: "Write" or "Edit"
|
|
24
|
+
manifest: Session manifest
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
ActionContext or None if required fields missing
|
|
28
|
+
"""
|
|
29
|
+
from forge.guard.types import ActionContext
|
|
30
|
+
|
|
31
|
+
tool_input = data.get("tool_input", {})
|
|
32
|
+
if not isinstance(tool_input, dict):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
target_path = tool_input.get("file_path") or tool_input.get("path")
|
|
36
|
+
if not isinstance(target_path, str):
|
|
37
|
+
target_path = None
|
|
38
|
+
|
|
39
|
+
cwd = Path.cwd().resolve()
|
|
40
|
+
if target_path:
|
|
41
|
+
try:
|
|
42
|
+
p = Path(target_path)
|
|
43
|
+
if p.is_absolute():
|
|
44
|
+
target_path = str(p.relative_to(cwd))
|
|
45
|
+
except (ValueError, RuntimeError):
|
|
46
|
+
# Keep as-is if can't make relative
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
new_content = None
|
|
50
|
+
if tool_name == "Write":
|
|
51
|
+
new_content = tool_input.get("content")
|
|
52
|
+
elif tool_name == "Edit":
|
|
53
|
+
new_content = tool_input.get("new_string")
|
|
54
|
+
|
|
55
|
+
if new_content and len(new_content) > 5000:
|
|
56
|
+
new_content = new_content[:5000] + "\n... (truncated)"
|
|
57
|
+
|
|
58
|
+
return ActionContext(
|
|
59
|
+
event=f"PreToolUse.{tool_name}",
|
|
60
|
+
tool_name=tool_name,
|
|
61
|
+
tool_args=tool_input,
|
|
62
|
+
repo_root=str(cwd),
|
|
63
|
+
session_name=manifest.name,
|
|
64
|
+
target_path=target_path,
|
|
65
|
+
new_content=new_content,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _persist_policy_state(
|
|
70
|
+
*,
|
|
71
|
+
store: SessionStore,
|
|
72
|
+
engine: Any,
|
|
73
|
+
result: Any,
|
|
74
|
+
effective: Any,
|
|
75
|
+
context_summary: str,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Persist policy state updates to session manifest.
|
|
78
|
+
|
|
79
|
+
Updates decision log and generic policy_states from stateful policies.
|
|
80
|
+
"""
|
|
81
|
+
from forge.guard.store import build_policy_state_update
|
|
82
|
+
from forge.session.models import PolicyConfirmed
|
|
83
|
+
|
|
84
|
+
collected_state = engine.get_collected_state()
|
|
85
|
+
|
|
86
|
+
def _mutate(m: object) -> None:
|
|
87
|
+
if not isinstance(m, SessionState):
|
|
88
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
89
|
+
|
|
90
|
+
existing = None
|
|
91
|
+
if m.confirmed.policy:
|
|
92
|
+
existing = {
|
|
93
|
+
"decisions": m.confirmed.policy.decisions,
|
|
94
|
+
"policy_states": m.confirmed.policy.policy_states,
|
|
95
|
+
"forge_version": m.confirmed.policy.forge_version,
|
|
96
|
+
"bundles": m.confirmed.policy.bundles,
|
|
97
|
+
"rules_active": m.confirmed.policy.rules_active,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
updated = build_policy_state_update(
|
|
101
|
+
result=result,
|
|
102
|
+
engine_state=collected_state,
|
|
103
|
+
existing_state=existing,
|
|
104
|
+
bundles=effective.policy.bundles if effective.policy else [],
|
|
105
|
+
rules_active=[p.policy_id for p in engine.policies],
|
|
106
|
+
context_summary=context_summary,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
m.confirmed.policy = PolicyConfirmed(
|
|
110
|
+
forge_version=updated.get("forge_version"),
|
|
111
|
+
bundles=updated.get("bundles", []),
|
|
112
|
+
rules_active=updated.get("rules_active", []),
|
|
113
|
+
decisions=updated.get("decisions", []),
|
|
114
|
+
policy_states=updated.get("policy_states", {}),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
m.confirmed.confirmed_at = now_iso()
|
|
118
|
+
m.confirmed.confirmed_by = "hook:policy-check"
|
|
119
|
+
|
|
120
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _derive_policy_source_label(result: Any, effective: Any) -> str:
|
|
124
|
+
"""Derive source label from the most relevant policy for the outcome.
|
|
125
|
+
|
|
126
|
+
For deny: the first denying policy (the blocker).
|
|
127
|
+
For non-deny: prefer the supervisor if it participated (the expensive check),
|
|
128
|
+
fall back to the first matching deterministic policy.
|
|
129
|
+
"""
|
|
130
|
+
sup = effective.policy.supervisor if effective.policy else None
|
|
131
|
+
sup_resume_id = sup.resume_id if sup else None
|
|
132
|
+
|
|
133
|
+
if result.final_decision == "deny":
|
|
134
|
+
for d in result.decisions:
|
|
135
|
+
if d.decision == "deny":
|
|
136
|
+
if d.policy_id == "semantic.supervisor" and sup_resume_id:
|
|
137
|
+
return f"'{sup_resume_id}'"
|
|
138
|
+
return d.policy_id
|
|
139
|
+
else:
|
|
140
|
+
# Non-deny: prefer supervisor if it evaluated
|
|
141
|
+
for d in result.decisions:
|
|
142
|
+
if d.policy_id == "semantic.supervisor":
|
|
143
|
+
if sup_resume_id:
|
|
144
|
+
return f"'{sup_resume_id}'"
|
|
145
|
+
return d.policy_id
|
|
146
|
+
# No supervisor — use first decision with matching outcome
|
|
147
|
+
for d in result.decisions:
|
|
148
|
+
if d.decision == result.final_decision:
|
|
149
|
+
return d.policy_id
|
|
150
|
+
|
|
151
|
+
return "policy"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Read hygiene hook: strip extra params from skill instruction file reads.
|
|
2
|
+
|
|
3
|
+
Skill instruction files (code.md, docs.md, code-openai.md, docs-gemini.md, etc.)
|
|
4
|
+
have a strict "file_path only" Read contract defined in SKILL.md. Models often
|
|
5
|
+
add offset/limit/pages anyway. This hook silently fixes the call via updatedInput
|
|
6
|
+
rather than blocking it — zero token cost, deterministic correction.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SKILL_RESOURCE_RE = re.compile(r"/skills/[^/]+/resources/")
|
|
19
|
+
_INSTRUCTION_BASENAME_RE = re.compile(r"^(code|docs)(-[a-z0-9_-]+)?\.md$")
|
|
20
|
+
_EXTRA_READ_PARAMS = {"offset", "limit", "pages"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_skill_instruction_file(file_path: str) -> bool:
|
|
24
|
+
"""Check if a path is a skill instruction file with a strict Read contract.
|
|
25
|
+
|
|
26
|
+
Three checks, all must pass:
|
|
27
|
+
1. Path contains /skills/<name>/resources/
|
|
28
|
+
2. File is an immediate child of resources/ (parent dir is "resources")
|
|
29
|
+
3. Basename matches {mode}.md or {mode}-{family}.md
|
|
30
|
+
"""
|
|
31
|
+
if not _SKILL_RESOURCE_RE.search(file_path):
|
|
32
|
+
return False
|
|
33
|
+
p = Path(file_path)
|
|
34
|
+
if p.parent.name != "resources":
|
|
35
|
+
return False
|
|
36
|
+
return bool(_INSTRUCTION_BASENAME_RE.fullmatch(p.name))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle_read_hygiene(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
40
|
+
"""Process a PreToolUse:Read event and strip extra params if needed.
|
|
41
|
+
|
|
42
|
+
Returns the hookSpecificOutput dict to print, or None if no fix needed.
|
|
43
|
+
"""
|
|
44
|
+
if data.get("hook_event_name") != "PreToolUse":
|
|
45
|
+
return None
|
|
46
|
+
if data.get("tool_name") != "Read":
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
tool_input = data.get("tool_input")
|
|
50
|
+
if not isinstance(tool_input, dict):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
file_path = tool_input.get("file_path")
|
|
54
|
+
if not isinstance(file_path, str):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not _is_skill_instruction_file(file_path):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
extra_keys = set(tool_input.keys()) & _EXTRA_READ_PARAMS
|
|
61
|
+
if not extra_keys:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
logger.debug("read-hygiene: stripped %s from %s", sorted(extra_keys), Path(file_path).name)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"hookSpecificOutput": {
|
|
68
|
+
"hookEventName": "PreToolUse",
|
|
69
|
+
"permissionDecision": "allow",
|
|
70
|
+
"updatedInput": {
|
|
71
|
+
"file_path": file_path,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|