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
forge/cli/extensions.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
"""Forge extensions commands (extensions lifecycle).
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- forge extension enable - Enable Forge extensions
|
|
5
|
+
- forge extension sync - Sync existing extensions
|
|
6
|
+
- forge extension disable - Disable extensions
|
|
7
|
+
- forge extension status - Show extensions status
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from forge.core.paths import display_path
|
|
21
|
+
from forge.install.exceptions import (
|
|
22
|
+
ForgeInstallError,
|
|
23
|
+
NoClaudeDirectoryError,
|
|
24
|
+
NoForgeInstallationError,
|
|
25
|
+
NotInstalledError,
|
|
26
|
+
SettingsConflictError,
|
|
27
|
+
TrackingCorruptedError,
|
|
28
|
+
)
|
|
29
|
+
from forge.install.installer import Installer, find_claude_root, find_forge_installation
|
|
30
|
+
from forge.install.models import (
|
|
31
|
+
FILE_MODULES,
|
|
32
|
+
InstallMode,
|
|
33
|
+
InstallModule,
|
|
34
|
+
InstallPlan,
|
|
35
|
+
InstallProfile,
|
|
36
|
+
InstallScope,
|
|
37
|
+
get_gated_skills,
|
|
38
|
+
)
|
|
39
|
+
from forge.install.tracking import TrackingStore
|
|
40
|
+
|
|
41
|
+
console = Console()
|
|
42
|
+
_log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
46
|
+
"""Walk up from *start* looking for ``.git``.
|
|
47
|
+
|
|
48
|
+
Returns the directory containing ``.git``, or None if not in a git repo.
|
|
49
|
+
Pure detector -- no side effects.
|
|
50
|
+
"""
|
|
51
|
+
current = start.resolve()
|
|
52
|
+
while current != current.parent:
|
|
53
|
+
if (current / ".git").exists():
|
|
54
|
+
return current
|
|
55
|
+
current = current.parent
|
|
56
|
+
if (current / ".git").exists():
|
|
57
|
+
return current
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _detect_git_project_root(start: Path | None = None) -> Path | None:
|
|
62
|
+
"""Find the git root suitable for auto-creating ``.claude/`` (Rule 4).
|
|
63
|
+
|
|
64
|
+
Returns the resolved git root, or None if not in a git repo or the
|
|
65
|
+
git root is the user's home directory. Pure detector -- no side effects.
|
|
66
|
+
"""
|
|
67
|
+
cwd = (start or Path.cwd()).resolve()
|
|
68
|
+
git_root = _find_git_root(cwd)
|
|
69
|
+
if git_root is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
home = Path.home().resolve()
|
|
73
|
+
if git_root == home:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return git_root.resolve()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _create_claude_dir(root: Path) -> None:
|
|
80
|
+
"""Create ``.claude/`` at *root* and log the action."""
|
|
81
|
+
claude_dir = root / ".claude"
|
|
82
|
+
claude_dir.mkdir(exist_ok=True)
|
|
83
|
+
_log.info("Created %s for Forge project", claude_dir)
|
|
84
|
+
console.print(f"[dim]Created {display_path(claude_dir)}[/dim]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_modules(modules_str: str | None) -> set[InstallModule] | None:
|
|
88
|
+
"""Parse comma-separated module names.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
modules_str: Comma-separated module names.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Set of InstallModule, or None if input is None/empty.
|
|
95
|
+
"""
|
|
96
|
+
if not modules_str:
|
|
97
|
+
return None
|
|
98
|
+
return {InstallModule(m.strip()) for m in modules_str.split(",")}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _count_actions(plan: InstallPlan) -> tuple[int, int]:
|
|
102
|
+
"""Count non-skip actions in a plan.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Tuple of (file_actions, settings_actions) that are not skips.
|
|
106
|
+
"""
|
|
107
|
+
file_actions = sum(1 for f in plan.files if f.action != "skip")
|
|
108
|
+
settings_actions = sum(1 for s in plan.settings if s.action != "skip")
|
|
109
|
+
return file_actions, settings_actions
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Modules that are intentionally empty in the source tree (only .gitkeep).
|
|
113
|
+
# Checked by allowlist so a broken wheel that omits skills/ still warns.
|
|
114
|
+
_INTENTIONALLY_EMPTY_MODULES: set[InstallModule] = {
|
|
115
|
+
InstallModule.AGENTS,
|
|
116
|
+
InstallModule.COMMANDS,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _warn_if_modules_have_no_files(
|
|
121
|
+
plan: InstallPlan,
|
|
122
|
+
scope: InstallScope,
|
|
123
|
+
project_root: Path | None,
|
|
124
|
+
tracking: TrackingStore,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Warn when a file-bearing module has no files anywhere (plan or tracking).
|
|
127
|
+
|
|
128
|
+
A clean install with 0 files in the plan is normal IF the existing
|
|
129
|
+
tracked install already has files for the module. But if neither plan
|
|
130
|
+
nor tracking has files for an enabled file-bearing module, the install
|
|
131
|
+
is broken — typically a wheel missing bundled extensions.
|
|
132
|
+
"""
|
|
133
|
+
enabled = {InstallModule(m) for m in plan.modules if InstallModule(m) in FILE_MODULES}
|
|
134
|
+
enabled -= _INTENTIONALLY_EMPTY_MODULES
|
|
135
|
+
if not enabled:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
project_str = None if scope == InstallScope.USER else (str(project_root) if project_root else None)
|
|
139
|
+
existing = tracking.get_installation(scope.value, project_str)
|
|
140
|
+
|
|
141
|
+
def _module_has_files(module: InstallModule, paths: list[str]) -> bool:
|
|
142
|
+
sep = f"/{module.value}/"
|
|
143
|
+
return any(sep in p for p in paths)
|
|
144
|
+
|
|
145
|
+
plan_paths = [f.target_path for f in plan.files]
|
|
146
|
+
existing_paths = [f.target_path for f in existing.files] if existing else []
|
|
147
|
+
|
|
148
|
+
missing = {m for m in enabled if not _module_has_files(m, plan_paths) and not _module_has_files(m, existing_paths)}
|
|
149
|
+
if not missing:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
names = ", ".join(sorted(m.value for m in missing))
|
|
153
|
+
console.print(
|
|
154
|
+
f"\n[yellow]Warning:[/yellow] No files found for enabled module(s): {names}. "
|
|
155
|
+
"Your Forge installation may be missing bundled extensions. "
|
|
156
|
+
"Try reinstalling: 'pip install --force-reinstall <wheel>'."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _print_completion_message(
|
|
161
|
+
plan: InstallPlan,
|
|
162
|
+
scope: InstallScope,
|
|
163
|
+
project_root: Path | None,
|
|
164
|
+
tracking: TrackingStore,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Print appropriate completion message based on what was done."""
|
|
167
|
+
file_actions, settings_actions = _count_actions(plan)
|
|
168
|
+
total_actions = file_actions + settings_actions
|
|
169
|
+
|
|
170
|
+
_warn_if_modules_have_no_files(plan, scope, project_root, tracking)
|
|
171
|
+
|
|
172
|
+
if total_actions == 0:
|
|
173
|
+
console.print("\n[dim]Already up to date.[/dim]")
|
|
174
|
+
else:
|
|
175
|
+
parts = []
|
|
176
|
+
if file_actions > 0:
|
|
177
|
+
parts.append(f"{file_actions} file{'s' if file_actions != 1 else ''}")
|
|
178
|
+
if settings_actions > 0:
|
|
179
|
+
parts.append(f"{settings_actions} setting{'s' if settings_actions != 1 else ''}")
|
|
180
|
+
console.print(f"\n[green]Extensions enabled.[/green] ({', '.join(parts)} updated)")
|
|
181
|
+
|
|
182
|
+
console.print("[dim]Tip: Customize permissions and env vars with 'forge claude preset edit'.[/dim]")
|
|
183
|
+
|
|
184
|
+
if InstallModule.SKILLS.value in plan.modules:
|
|
185
|
+
console.print(
|
|
186
|
+
"[dim]Tip: Multi-model skills require proxy credentials. " "Run 'forge auth status' to check.[/dim]"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
profile = InstallProfile(plan.profile)
|
|
190
|
+
gated = get_gated_skills(profile)
|
|
191
|
+
if gated:
|
|
192
|
+
skill_list = ", ".join(f"/forge:{name}" for name, _ in gated)
|
|
193
|
+
required = gated[0][1].value
|
|
194
|
+
console.print(f"\n[dim]Tip: Additional skills available with --profile {required}: {skill_list}[/dim]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_anchor(anchor: Path) -> None:
|
|
198
|
+
"""Reject anchors that point inside a ``.claude/`` directory.
|
|
199
|
+
|
|
200
|
+
The ``.claude/`` creation in ``enable_cmd`` runs before the installer's
|
|
201
|
+
``get_target_root()`` guard, so an anchor like ``/repo/.claude`` would
|
|
202
|
+
create ``/repo/.claude/.claude/`` before the guard fires.
|
|
203
|
+
"""
|
|
204
|
+
resolved = anchor.expanduser().resolve()
|
|
205
|
+
if ".claude" in resolved.parts:
|
|
206
|
+
raise click.UsageError(
|
|
207
|
+
f"--root points inside a .claude directory: {anchor}\n"
|
|
208
|
+
"Provide the project root instead (the parent of .claude/)."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _resolve_project_root(
|
|
213
|
+
scope: InstallScope,
|
|
214
|
+
*,
|
|
215
|
+
anchor: Path | None = None,
|
|
216
|
+
auto_create: bool = False,
|
|
217
|
+
) -> Path | None:
|
|
218
|
+
"""Resolve canonical project root for a given scope.
|
|
219
|
+
|
|
220
|
+
For user scope, returns None.
|
|
221
|
+
For project/local scope, finds the .claude directory and returns
|
|
222
|
+
the canonicalized project root. When *auto_create* is True and no
|
|
223
|
+
``.claude/`` exists, creates it at the git root (Rule 4).
|
|
224
|
+
|
|
225
|
+
When *anchor* is provided, skips the walk-up and uses that path directly.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
scope: The installation scope.
|
|
229
|
+
anchor: Explicit target directory (skips walk-up when set).
|
|
230
|
+
auto_create: Whether to create ``.claude/`` if missing (Rule 4).
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Canonicalized project root path, or None for user scope.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
NoClaudeDirectoryError: If no .claude directory found and auto-create
|
|
237
|
+
is disabled or not in a git repo.
|
|
238
|
+
"""
|
|
239
|
+
if scope == InstallScope.USER:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
if anchor is not None:
|
|
243
|
+
resolved = anchor.expanduser().resolve()
|
|
244
|
+
if auto_create and not (resolved / ".claude").is_dir():
|
|
245
|
+
_create_claude_dir(resolved)
|
|
246
|
+
return resolved
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
_detected_scope, project_root = find_claude_root()
|
|
250
|
+
except NoClaudeDirectoryError:
|
|
251
|
+
# find_claude_root raises when walk reaches FS root without home;
|
|
252
|
+
# treat the same as "no .claude/ found" for auto-create purposes.
|
|
253
|
+
project_root = None
|
|
254
|
+
|
|
255
|
+
if project_root is None:
|
|
256
|
+
# Rule 4: auto-create .claude/ at git root for project/local enable
|
|
257
|
+
git_root = _detect_git_project_root()
|
|
258
|
+
if git_root is not None:
|
|
259
|
+
if auto_create:
|
|
260
|
+
_create_claude_dir(git_root)
|
|
261
|
+
return git_root
|
|
262
|
+
raise NoClaudeDirectoryError(
|
|
263
|
+
"No .claude directory found. Use '--scope user' for global install, "
|
|
264
|
+
"or run from within a Claude Code project."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Canonicalize to handle symlinks and ensure consistent keys
|
|
268
|
+
return project_root.resolve()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _print_plan(plan: InstallPlan, dry_run: bool = False) -> None:
|
|
272
|
+
"""Print installation plan using Rich.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
plan: The plan to display.
|
|
276
|
+
dry_run: If True, prefix output with "(dry-run)".
|
|
277
|
+
"""
|
|
278
|
+
prefix = "[dim](dry-run)[/dim] " if dry_run else ""
|
|
279
|
+
|
|
280
|
+
console.print(f"\n{prefix}[bold]Installation Plan[/bold]")
|
|
281
|
+
console.print(f" Scope: {plan.scope}")
|
|
282
|
+
console.print(f" Mode: {plan.mode}")
|
|
283
|
+
console.print(f" Profile: {plan.profile}")
|
|
284
|
+
console.print(f" Modules: {', '.join(plan.modules)}")
|
|
285
|
+
|
|
286
|
+
if plan.files:
|
|
287
|
+
console.print(f"\n{prefix}[bold]Files:[/bold]")
|
|
288
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
289
|
+
table.add_column("ACTION", style="dim")
|
|
290
|
+
table.add_column("PATH")
|
|
291
|
+
table.add_column("REASON", style="dim")
|
|
292
|
+
|
|
293
|
+
for f in plan.files:
|
|
294
|
+
style = {
|
|
295
|
+
"install": "green",
|
|
296
|
+
"update": "yellow",
|
|
297
|
+
"skip": "dim",
|
|
298
|
+
"conflict": "red",
|
|
299
|
+
}.get(f.action, "")
|
|
300
|
+
table.add_row(f.action, display_path(f.target_path), f.reason or "", style=style)
|
|
301
|
+
|
|
302
|
+
console.print(table)
|
|
303
|
+
|
|
304
|
+
if plan.settings:
|
|
305
|
+
console.print(f"\n{prefix}[bold]Settings:[/bold]")
|
|
306
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
307
|
+
table.add_column("ACTION", style="dim")
|
|
308
|
+
table.add_column("KEY")
|
|
309
|
+
table.add_column("VALUE", style="dim")
|
|
310
|
+
|
|
311
|
+
for s in plan.settings:
|
|
312
|
+
style = "red" if s.action == "conflict" else ""
|
|
313
|
+
value_str = str(s.value) if s.value else ""
|
|
314
|
+
if s.action == "conflict":
|
|
315
|
+
value_str = f"current={s.current_value!r}, forge={s.value!r}"
|
|
316
|
+
table.add_row(s.action, s.key_path, value_str, style=style)
|
|
317
|
+
|
|
318
|
+
console.print(table)
|
|
319
|
+
|
|
320
|
+
if plan.has_conflicts:
|
|
321
|
+
console.print(f"\n{prefix}[bold red]Conflicts detected:[/bold red]")
|
|
322
|
+
for c in plan.conflicts:
|
|
323
|
+
console.print(f" [red]- {c}[/red]")
|
|
324
|
+
console.print("\n[dim]Tip: Use --force to override, or resolve conflicts manually.[/dim]")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _uninstall_all_installations(tracking: TrackingStore, yes: bool) -> None:
|
|
328
|
+
"""Uninstall all tracked installations.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
tracking: TrackingStore instance.
|
|
332
|
+
yes: If True, skip confirmation prompt.
|
|
333
|
+
"""
|
|
334
|
+
installations = tracking.list_installations()
|
|
335
|
+
|
|
336
|
+
if not installations:
|
|
337
|
+
console.print("[dim]No Forge installations found.[/dim]")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
console.print(f"[bold]Found {len(installations)} Forge installation(s):[/bold]\n")
|
|
341
|
+
|
|
342
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
343
|
+
table.add_column("SCOPE", style="cyan")
|
|
344
|
+
table.add_column("PROJECT PATH")
|
|
345
|
+
table.add_column("PROFILE")
|
|
346
|
+
table.add_column("FILES")
|
|
347
|
+
|
|
348
|
+
for scope, project_path, installation in installations:
|
|
349
|
+
scope_display = scope
|
|
350
|
+
path_display = project_path or "(global)"
|
|
351
|
+
if len(path_display) > 40:
|
|
352
|
+
path_display = "…" + path_display[-37:]
|
|
353
|
+
table.add_row(
|
|
354
|
+
scope_display,
|
|
355
|
+
path_display,
|
|
356
|
+
installation.profile,
|
|
357
|
+
str(len(installation.files)),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
console.print(table)
|
|
361
|
+
console.print()
|
|
362
|
+
|
|
363
|
+
if not yes:
|
|
364
|
+
if not click.confirm("Disable ALL of these?"):
|
|
365
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
errors = []
|
|
369
|
+
for scope, project_path, _installation in installations:
|
|
370
|
+
try:
|
|
371
|
+
console.print(f"\n[bold]Disabling {scope}[/bold]", end="")
|
|
372
|
+
if project_path:
|
|
373
|
+
console.print(f" [dim]({display_path(project_path)})[/dim]")
|
|
374
|
+
else:
|
|
375
|
+
console.print()
|
|
376
|
+
|
|
377
|
+
install_scope = InstallScope(scope)
|
|
378
|
+
project_root = Path(project_path) if project_path else None
|
|
379
|
+
|
|
380
|
+
installer = Installer(scope=install_scope, project_root=project_root)
|
|
381
|
+
installer.uninstall()
|
|
382
|
+
console.print(" [green]✓ Done[/green]")
|
|
383
|
+
|
|
384
|
+
except ForgeInstallError as e:
|
|
385
|
+
console.print(f" [red]✗ Failed: {e}[/red]")
|
|
386
|
+
errors.append((scope, project_path, str(e)))
|
|
387
|
+
|
|
388
|
+
console.print()
|
|
389
|
+
if errors:
|
|
390
|
+
console.print(f"[yellow]Completed with {len(errors)} error(s).[/yellow]")
|
|
391
|
+
for scope, path, err in errors:
|
|
392
|
+
console.print(f" [red]- {scope} ({display_path(path) if path else 'global'}): {err}[/red]")
|
|
393
|
+
else:
|
|
394
|
+
console.print(f"[green]All {len(installations)} installation(s) disabled.[/green]")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _can_resolve_project_root(scope: InstallScope, *, anchor: Path | None = None) -> bool:
|
|
398
|
+
"""Check if project root can be resolved without raising."""
|
|
399
|
+
try:
|
|
400
|
+
_resolve_project_root(scope, anchor=anchor)
|
|
401
|
+
return True
|
|
402
|
+
except NoClaudeDirectoryError:
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# --- Commands ---
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
410
|
+
def extensions() -> None:
|
|
411
|
+
"""Manage Forge extensions lifecycle.
|
|
412
|
+
|
|
413
|
+
\b
|
|
414
|
+
Examples:
|
|
415
|
+
forge extension enable # Auto-detect scope, enable
|
|
416
|
+
forge extension status # Show installation status
|
|
417
|
+
forge extension sync # Sync to latest version
|
|
418
|
+
"""
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@extensions.command("enable")
|
|
423
|
+
@click.option(
|
|
424
|
+
"--scope",
|
|
425
|
+
"-S",
|
|
426
|
+
type=click.Choice(["local", "project", "user"]),
|
|
427
|
+
default=None,
|
|
428
|
+
help="Installation scope: local (gitignored), project (committed), user (global)",
|
|
429
|
+
)
|
|
430
|
+
@click.option(
|
|
431
|
+
"--root",
|
|
432
|
+
"path",
|
|
433
|
+
type=click.Path(exists=True, file_okay=False, resolve_path=True),
|
|
434
|
+
default=None,
|
|
435
|
+
help="Target directory (default: walk up from cwd to find .claude/)",
|
|
436
|
+
)
|
|
437
|
+
@click.option(
|
|
438
|
+
"--profile",
|
|
439
|
+
"-p",
|
|
440
|
+
type=click.Choice(["minimal", "standard", "full"]),
|
|
441
|
+
default="standard",
|
|
442
|
+
help="Installation profile",
|
|
443
|
+
)
|
|
444
|
+
@click.option(
|
|
445
|
+
"--copy",
|
|
446
|
+
"-c",
|
|
447
|
+
"mode",
|
|
448
|
+
flag_value="copy",
|
|
449
|
+
default=True,
|
|
450
|
+
help="Copy files (default)",
|
|
451
|
+
)
|
|
452
|
+
@click.option(
|
|
453
|
+
"--symlink",
|
|
454
|
+
"-s",
|
|
455
|
+
"mode",
|
|
456
|
+
flag_value="symlink",
|
|
457
|
+
help="Symlink files (dev mode)",
|
|
458
|
+
)
|
|
459
|
+
@click.option(
|
|
460
|
+
"--with",
|
|
461
|
+
"-w",
|
|
462
|
+
"with_modules",
|
|
463
|
+
help="Add modules (comma-separated: commands,agents,skills,hooks,status-line,permissions)",
|
|
464
|
+
)
|
|
465
|
+
@click.option(
|
|
466
|
+
"--without",
|
|
467
|
+
"-W",
|
|
468
|
+
"without_modules",
|
|
469
|
+
help="Remove modules (comma-separated)",
|
|
470
|
+
)
|
|
471
|
+
@click.option("--force", "-f", is_flag=True, help="Override conflicts")
|
|
472
|
+
@click.option("--dry-run", "-n", is_flag=True, help="Show plan without executing")
|
|
473
|
+
def enable_cmd(
|
|
474
|
+
scope: str | None,
|
|
475
|
+
path: str | None,
|
|
476
|
+
profile: str,
|
|
477
|
+
mode: str,
|
|
478
|
+
with_modules: str | None,
|
|
479
|
+
without_modules: str | None,
|
|
480
|
+
force: bool,
|
|
481
|
+
dry_run: bool,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Enable Forge extensions.
|
|
484
|
+
|
|
485
|
+
\b
|
|
486
|
+
Scope Detection (when no --scope specified):
|
|
487
|
+
Walks up from current directory looking for a .claude/ directory.
|
|
488
|
+
- If found: enables local in that project's .claude/settings.local.json
|
|
489
|
+
- If in a git repo: enables local at the git root
|
|
490
|
+
- If reached ~: enables user in ~/.claude/settings.json
|
|
491
|
+
- If not found: fails (use --scope user outside a project)
|
|
492
|
+
|
|
493
|
+
\b
|
|
494
|
+
Examples:
|
|
495
|
+
forge extension enable # Auto-detect scope
|
|
496
|
+
forge extension enable --scope local # Local at nearest .claude/
|
|
497
|
+
forge extension enable --scope local --root /repo/api # Local at specific path
|
|
498
|
+
forge extension enable --root /repo/api # Same (defaults to local)
|
|
499
|
+
forge extension enable --scope user # Global ~/.claude
|
|
500
|
+
forge extension enable --profile minimal # Commands only
|
|
501
|
+
forge extension enable --dry-run # Preview changes
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
# Check Claude Code minimum version (hard-block: reject over warn)
|
|
505
|
+
from forge.install.version import check_minimum_version
|
|
506
|
+
|
|
507
|
+
version_check = check_minimum_version()
|
|
508
|
+
if not version_check.ok:
|
|
509
|
+
console.print(f"[red]Error:[/red] {version_check.reason}")
|
|
510
|
+
console.print("\n[dim]Tip: Run 'claude update' to upgrade.[/dim]")
|
|
511
|
+
sys.exit(1)
|
|
512
|
+
|
|
513
|
+
anchor = Path(path) if path else None
|
|
514
|
+
|
|
515
|
+
# Validate: --scope user + --root is contradictory
|
|
516
|
+
if scope == "user" and anchor is not None:
|
|
517
|
+
raise click.UsageError("--scope user is global; --root is not applicable.")
|
|
518
|
+
|
|
519
|
+
# Validate: anchor must not point inside .claude/
|
|
520
|
+
if anchor is not None:
|
|
521
|
+
_validate_anchor(anchor)
|
|
522
|
+
|
|
523
|
+
# Default: --root without --scope implies local
|
|
524
|
+
if anchor is not None and scope is None:
|
|
525
|
+
scope = "local"
|
|
526
|
+
|
|
527
|
+
# --- Scope resolution (Rule 4: auto-create .claude/ in git repos) ---
|
|
528
|
+
needs_create = False
|
|
529
|
+
|
|
530
|
+
if scope is None:
|
|
531
|
+
install_scope, project_root = find_claude_root()
|
|
532
|
+
# P1 fix: auto-detect in a git repo should prefer LOCAL over USER
|
|
533
|
+
if install_scope == InstallScope.USER:
|
|
534
|
+
git_root = _detect_git_project_root()
|
|
535
|
+
if git_root is not None:
|
|
536
|
+
install_scope = InstallScope.LOCAL
|
|
537
|
+
project_root = git_root
|
|
538
|
+
needs_create = not (git_root / ".claude").is_dir()
|
|
539
|
+
console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
|
|
540
|
+
else:
|
|
541
|
+
install_scope = InstallScope(scope)
|
|
542
|
+
project_root = _resolve_project_root(install_scope, anchor=anchor, auto_create=False)
|
|
543
|
+
if project_root is not None:
|
|
544
|
+
needs_create = not (project_root / ".claude").is_dir()
|
|
545
|
+
|
|
546
|
+
# Create .claude/ only when not dry-run
|
|
547
|
+
if needs_create and project_root is not None:
|
|
548
|
+
if dry_run:
|
|
549
|
+
console.print(f"[dim]Would create {display_path(project_root / '.claude')}[/dim]")
|
|
550
|
+
else:
|
|
551
|
+
_create_claude_dir(project_root)
|
|
552
|
+
|
|
553
|
+
# Rule 1 anchor: .forge/ is required for session start.
|
|
554
|
+
# Preview in dry-run; actual creation deferred until installer succeeds.
|
|
555
|
+
needs_forge = project_root is not None and not (project_root / ".forge").is_dir()
|
|
556
|
+
if needs_forge and dry_run and project_root is not None:
|
|
557
|
+
console.print(f"[dim]Would create {display_path(project_root / '.forge')}[/dim]")
|
|
558
|
+
|
|
559
|
+
install_profile = InstallProfile(profile)
|
|
560
|
+
install_mode = InstallMode(mode)
|
|
561
|
+
|
|
562
|
+
installer = Installer(scope=install_scope, project_root=project_root)
|
|
563
|
+
|
|
564
|
+
if dry_run:
|
|
565
|
+
plan = installer.plan(
|
|
566
|
+
profile=install_profile,
|
|
567
|
+
mode=install_mode,
|
|
568
|
+
with_modules=_parse_modules(with_modules),
|
|
569
|
+
without_modules=_parse_modules(without_modules),
|
|
570
|
+
force=force,
|
|
571
|
+
)
|
|
572
|
+
_print_plan(plan, dry_run=True)
|
|
573
|
+
if plan.has_conflicts:
|
|
574
|
+
sys.exit(1)
|
|
575
|
+
else:
|
|
576
|
+
plan = installer.init(
|
|
577
|
+
profile=install_profile,
|
|
578
|
+
mode=install_mode,
|
|
579
|
+
with_modules=_parse_modules(with_modules),
|
|
580
|
+
without_modules=_parse_modules(without_modules),
|
|
581
|
+
force=force,
|
|
582
|
+
)
|
|
583
|
+
_print_plan(plan)
|
|
584
|
+
if plan.has_conflicts:
|
|
585
|
+
console.print("\n[red]Enable failed due to conflicts.[/red]")
|
|
586
|
+
sys.exit(1)
|
|
587
|
+
else:
|
|
588
|
+
# Create .forge/ only after installer succeeds (avoids orphaned
|
|
589
|
+
# directories if enable fails due to conflicts).
|
|
590
|
+
if needs_forge and project_root is not None:
|
|
591
|
+
(project_root / ".forge").mkdir(exist_ok=True)
|
|
592
|
+
_log.info("Created %s for session state", project_root / ".forge")
|
|
593
|
+
|
|
594
|
+
_print_completion_message(plan, install_scope, project_root, TrackingStore())
|
|
595
|
+
|
|
596
|
+
except click.UsageError:
|
|
597
|
+
raise
|
|
598
|
+
except NoClaudeDirectoryError as e:
|
|
599
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
600
|
+
console.print(
|
|
601
|
+
"\n[dim]Tip: Use '--scope user' to enable globally, "
|
|
602
|
+
"or '--root <dir>' to target a specific directory.[/dim]"
|
|
603
|
+
)
|
|
604
|
+
sys.exit(1)
|
|
605
|
+
except SettingsConflictError as e:
|
|
606
|
+
console.print(f"[red]Settings conflict:[/red] {e}")
|
|
607
|
+
console.print("\n[dim]Tip: Use --force to override.[/dim]")
|
|
608
|
+
sys.exit(1)
|
|
609
|
+
except ForgeInstallError as e:
|
|
610
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
611
|
+
sys.exit(1)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@extensions.command("sync")
|
|
615
|
+
@click.option(
|
|
616
|
+
"--scope",
|
|
617
|
+
"-S",
|
|
618
|
+
type=click.Choice(["local", "project", "user"]),
|
|
619
|
+
default=None,
|
|
620
|
+
help="Installation scope",
|
|
621
|
+
)
|
|
622
|
+
@click.option("--force", "-f", is_flag=True, help="Override conflicts")
|
|
623
|
+
def sync_cmd(scope: str | None, force: bool) -> None:
|
|
624
|
+
"""Sync existing Forge extensions.
|
|
625
|
+
|
|
626
|
+
Re-runs the enable with the same profile and mode as originally
|
|
627
|
+
configured, refreshing all files and settings from the current Forge
|
|
628
|
+
source.
|
|
629
|
+
|
|
630
|
+
\b
|
|
631
|
+
Scope Detection (when no --scope specified):
|
|
632
|
+
Walks up from current directory looking for existing Forge extensions
|
|
633
|
+
(detected by .settings.*.json.forge.* files in .claude/).
|
|
634
|
+
- Checks LOCAL first, then PROJECT, then USER
|
|
635
|
+
- Fails if no extensions found
|
|
636
|
+
|
|
637
|
+
\b
|
|
638
|
+
Examples:
|
|
639
|
+
forge extension sync # Sync Forge extensions
|
|
640
|
+
forge extension sync --scope local # Sync local scope
|
|
641
|
+
forge extension sync --force # Force re-sync
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
# Check Claude Code minimum version (same gate as enable)
|
|
645
|
+
from forge.install.version import check_minimum_version
|
|
646
|
+
|
|
647
|
+
version_check = check_minimum_version()
|
|
648
|
+
if not version_check.ok:
|
|
649
|
+
console.print(f"[red]Error:[/red] {version_check.reason}")
|
|
650
|
+
console.print("\n[dim]Tip: Run 'claude update' to upgrade.[/dim]")
|
|
651
|
+
sys.exit(1)
|
|
652
|
+
|
|
653
|
+
if scope is None:
|
|
654
|
+
install_scope, project_root = find_forge_installation()
|
|
655
|
+
console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
|
|
656
|
+
else:
|
|
657
|
+
install_scope = InstallScope(scope)
|
|
658
|
+
# Use canonical project root (finds .claude/ and resolves symlinks)
|
|
659
|
+
project_root = _resolve_project_root(install_scope)
|
|
660
|
+
|
|
661
|
+
installer = Installer(scope=install_scope, project_root=project_root)
|
|
662
|
+
plan = installer.update(force=force)
|
|
663
|
+
|
|
664
|
+
_print_plan(plan)
|
|
665
|
+
if plan.has_conflicts:
|
|
666
|
+
console.print("\n[red]Sync failed due to conflicts.[/red]")
|
|
667
|
+
sys.exit(1)
|
|
668
|
+
else:
|
|
669
|
+
file_actions, settings_actions = _count_actions(plan)
|
|
670
|
+
total_actions = file_actions + settings_actions
|
|
671
|
+
if total_actions == 0:
|
|
672
|
+
console.print("\n[dim]Already up to date.[/dim]")
|
|
673
|
+
else:
|
|
674
|
+
parts = []
|
|
675
|
+
if file_actions > 0:
|
|
676
|
+
parts.append(f"{file_actions} file{'s' if file_actions != 1 else ''}")
|
|
677
|
+
if settings_actions > 0:
|
|
678
|
+
parts.append(f"{settings_actions} setting{'s' if settings_actions != 1 else ''}")
|
|
679
|
+
console.print(f"\n[green]Sync complete.[/green] ({', '.join(parts)} updated)")
|
|
680
|
+
|
|
681
|
+
except NoForgeInstallationError as e:
|
|
682
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
683
|
+
sys.exit(1)
|
|
684
|
+
except NotInstalledError as e:
|
|
685
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
686
|
+
console.print("\n[dim]Tip: Run 'forge extension enable' first.[/dim]")
|
|
687
|
+
sys.exit(1)
|
|
688
|
+
except ForgeInstallError as e:
|
|
689
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
690
|
+
sys.exit(1)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@extensions.command("disable")
|
|
694
|
+
@click.option(
|
|
695
|
+
"--scope",
|
|
696
|
+
"-S",
|
|
697
|
+
type=click.Choice(["local", "project", "user"]),
|
|
698
|
+
default=None,
|
|
699
|
+
help="Installation scope",
|
|
700
|
+
)
|
|
701
|
+
@click.option(
|
|
702
|
+
"--all",
|
|
703
|
+
"-a",
|
|
704
|
+
"uninstall_all",
|
|
705
|
+
is_flag=True,
|
|
706
|
+
help="Disable ALL tracked installations",
|
|
707
|
+
)
|
|
708
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
709
|
+
@click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
|
|
710
|
+
def disable_cmd(scope: str | None, uninstall_all: bool, yes: bool, force: bool) -> None:
|
|
711
|
+
"""Disable Forge extensions.
|
|
712
|
+
|
|
713
|
+
Removes only files and settings entries that were added by Forge.
|
|
714
|
+
User modifications are preserved.
|
|
715
|
+
|
|
716
|
+
\b
|
|
717
|
+
Scope Detection (when no --scope/--all specified):
|
|
718
|
+
Walks up from current directory looking for existing Forge extensions
|
|
719
|
+
(detected by .settings.*.json.forge.* files in .claude/).
|
|
720
|
+
- Checks LOCAL first, then PROJECT, then USER
|
|
721
|
+
- Fails if no extensions found
|
|
722
|
+
|
|
723
|
+
\b
|
|
724
|
+
--all mode:
|
|
725
|
+
Disables ALL tracked installations (user + all local/project).
|
|
726
|
+
Uses ~/.forge/installed.json to find all installations.
|
|
727
|
+
|
|
728
|
+
\b
|
|
729
|
+
Examples:
|
|
730
|
+
forge extension disable # Auto-detect scope
|
|
731
|
+
forge extension disable --scope local # Disable local scope
|
|
732
|
+
forge extension disable --all --yes # Disable everything
|
|
733
|
+
"""
|
|
734
|
+
yes = yes or force
|
|
735
|
+
|
|
736
|
+
if uninstall_all and scope is not None:
|
|
737
|
+
raise click.UsageError("--all and --scope are mutually exclusive.")
|
|
738
|
+
try:
|
|
739
|
+
tracking = TrackingStore()
|
|
740
|
+
|
|
741
|
+
if uninstall_all:
|
|
742
|
+
_uninstall_all_installations(tracking, yes)
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
if scope is None:
|
|
746
|
+
install_scope, project_root = find_forge_installation()
|
|
747
|
+
console.print(f"[dim]Auto-detected scope: {install_scope.value}[/dim]")
|
|
748
|
+
else:
|
|
749
|
+
install_scope = InstallScope(scope)
|
|
750
|
+
# Use canonical project root (finds .claude/ and resolves symlinks)
|
|
751
|
+
project_root = _resolve_project_root(install_scope)
|
|
752
|
+
|
|
753
|
+
project_path_str = str(project_root) if project_root else None
|
|
754
|
+
existing = tracking.get_installation(install_scope.value, project_path_str)
|
|
755
|
+
|
|
756
|
+
if existing is None:
|
|
757
|
+
console.print(f"[dim]No Forge installation for scope '{install_scope.value}'.[/dim]")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
console.print(f"[bold]Will disable Forge extensions ({install_scope.value}):[/bold]")
|
|
761
|
+
console.print(f" Profile: {existing.profile}")
|
|
762
|
+
console.print(f" Mode: {existing.mode}")
|
|
763
|
+
console.print()
|
|
764
|
+
|
|
765
|
+
if existing.files:
|
|
766
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
767
|
+
table.add_column("ACTION", style="red")
|
|
768
|
+
table.add_column("PATH")
|
|
769
|
+
for f in existing.files:
|
|
770
|
+
# Truncate long paths for display
|
|
771
|
+
path_str = str(f.target_path)
|
|
772
|
+
if len(path_str) > 60:
|
|
773
|
+
path_str = path_str[:57] + "…"
|
|
774
|
+
table.add_row("remove", path_str)
|
|
775
|
+
console.print("[bold]Files:[/bold]")
|
|
776
|
+
console.print(table)
|
|
777
|
+
console.print()
|
|
778
|
+
|
|
779
|
+
if existing.settings_entries:
|
|
780
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
781
|
+
table.add_column("ACTION", style="red")
|
|
782
|
+
table.add_column("KEY")
|
|
783
|
+
for entry in existing.settings_entries:
|
|
784
|
+
table.add_row("unmerge", entry.key_path)
|
|
785
|
+
console.print("[bold]Settings:[/bold]")
|
|
786
|
+
console.print(table)
|
|
787
|
+
|
|
788
|
+
if not (force or yes):
|
|
789
|
+
if not click.confirm("\nProceed with disable?"):
|
|
790
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
installer = Installer(scope=install_scope, project_root=project_root)
|
|
794
|
+
installer.uninstall()
|
|
795
|
+
|
|
796
|
+
# Remove .forge/ anchor if it's empty (no sessions, artifacts, etc.).
|
|
797
|
+
# .claude/ is NOT removed — it may contain user-authored content.
|
|
798
|
+
if project_root is not None:
|
|
799
|
+
forge_dir = project_root / ".forge"
|
|
800
|
+
if forge_dir.is_dir():
|
|
801
|
+
try:
|
|
802
|
+
forge_dir.rmdir() # Only succeeds if empty
|
|
803
|
+
_log.info("Removed empty %s", forge_dir)
|
|
804
|
+
except OSError:
|
|
805
|
+
pass # Non-empty: sessions/artifacts still present
|
|
806
|
+
|
|
807
|
+
console.print("\n[green]Extensions disabled.[/green]")
|
|
808
|
+
|
|
809
|
+
except NoForgeInstallationError as e:
|
|
810
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
811
|
+
sys.exit(1)
|
|
812
|
+
except ForgeInstallError as e:
|
|
813
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
814
|
+
sys.exit(1)
|
|
815
|
+
except TrackingCorruptedError as e:
|
|
816
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
817
|
+
sys.exit(1)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@extensions.command("status")
|
|
821
|
+
@click.option(
|
|
822
|
+
"--scope",
|
|
823
|
+
"-S",
|
|
824
|
+
type=click.Choice(["local", "project", "user"]),
|
|
825
|
+
default=None,
|
|
826
|
+
help="Installation scope",
|
|
827
|
+
)
|
|
828
|
+
@click.option(
|
|
829
|
+
"--root",
|
|
830
|
+
"path",
|
|
831
|
+
type=click.Path(exists=True, file_okay=False, resolve_path=True),
|
|
832
|
+
default=None,
|
|
833
|
+
help="Target directory to check (default: walk up from cwd)",
|
|
834
|
+
)
|
|
835
|
+
@click.option("--all", "-a", "show_all", is_flag=True, help="Show all scopes")
|
|
836
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
837
|
+
def status_cmd(scope: str | None, path: str | None, show_all: bool, as_json: bool) -> None:
|
|
838
|
+
"""Show extensions status.
|
|
839
|
+
|
|
840
|
+
Displays what Forge has enabled in the specified scope(s).
|
|
841
|
+
|
|
842
|
+
\b
|
|
843
|
+
Scope Detection (when no --scope/--all specified):
|
|
844
|
+
Walks up from current directory looking for existing Forge installations
|
|
845
|
+
(detected by .settings.*.json.forge.* files in .claude/).
|
|
846
|
+
- Checks LOCAL first, then PROJECT, then USER
|
|
847
|
+
- If no installation found, shows all scopes for informational purposes
|
|
848
|
+
|
|
849
|
+
\b
|
|
850
|
+
Examples:
|
|
851
|
+
forge extension status # Auto-detect
|
|
852
|
+
forge extension status --scope local --root /repo/api # Check specific install
|
|
853
|
+
forge extension status --root /repo/api # Auto-detect scope at path
|
|
854
|
+
forge extension status --all # Show all scopes
|
|
855
|
+
"""
|
|
856
|
+
import os
|
|
857
|
+
|
|
858
|
+
anchor = Path(path) if path else None
|
|
859
|
+
|
|
860
|
+
if show_all and scope is not None:
|
|
861
|
+
raise click.UsageError("--all and --scope are mutually exclusive.")
|
|
862
|
+
if show_all and anchor is not None:
|
|
863
|
+
raise click.UsageError("--all and --root are mutually exclusive.")
|
|
864
|
+
if scope == "user" and anchor is not None:
|
|
865
|
+
raise click.UsageError("--scope user is global; --root is not applicable.")
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
tracking = TrackingStore()
|
|
869
|
+
tracking.read()
|
|
870
|
+
except TrackingCorruptedError as e:
|
|
871
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
872
|
+
raise SystemExit(1) from None
|
|
873
|
+
|
|
874
|
+
cwd = os.getcwd()
|
|
875
|
+
|
|
876
|
+
# When auto-detect finds the real install root (which may differ from
|
|
877
|
+
# anchor if --root points at a subdirectory), use it for tracking lookups.
|
|
878
|
+
detected_root: Path | None = None
|
|
879
|
+
|
|
880
|
+
detected_scope_name: str | None = None
|
|
881
|
+
if show_all:
|
|
882
|
+
scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
|
|
883
|
+
elif scope is None and anchor is None:
|
|
884
|
+
try:
|
|
885
|
+
detected_scope, detected_root = find_forge_installation()
|
|
886
|
+
detected_scope_name = detected_scope.value
|
|
887
|
+
scopes = [detected_scope]
|
|
888
|
+
except NoForgeInstallationError:
|
|
889
|
+
scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
|
|
890
|
+
elif scope is None and anchor is not None:
|
|
891
|
+
# --root without --scope: auto-detect scope at that path
|
|
892
|
+
try:
|
|
893
|
+
detected_scope, detected_root = find_forge_installation(start=anchor)
|
|
894
|
+
detected_scope_name = detected_scope.value
|
|
895
|
+
scopes = [detected_scope]
|
|
896
|
+
except NoForgeInstallationError:
|
|
897
|
+
scopes = [InstallScope.USER, InstallScope.PROJECT, InstallScope.LOCAL]
|
|
898
|
+
else:
|
|
899
|
+
scopes = [InstallScope(scope)]
|
|
900
|
+
|
|
901
|
+
# Use the detected root (from walk-up) over the raw anchor for lookups.
|
|
902
|
+
effective_anchor = detected_root if detected_root is not None else anchor
|
|
903
|
+
|
|
904
|
+
if as_json:
|
|
905
|
+
import json
|
|
906
|
+
|
|
907
|
+
data = []
|
|
908
|
+
for s in scopes:
|
|
909
|
+
try:
|
|
910
|
+
project_root = _resolve_project_root(s, anchor=effective_anchor)
|
|
911
|
+
project_path_str = str(project_root) if project_root else None
|
|
912
|
+
except NoClaudeDirectoryError:
|
|
913
|
+
project_path_str = None
|
|
914
|
+
|
|
915
|
+
inst = tracking.get_installation(s.value, project_path_str)
|
|
916
|
+
if inst is None:
|
|
917
|
+
continue
|
|
918
|
+
data.append(
|
|
919
|
+
{
|
|
920
|
+
"scope": s.value,
|
|
921
|
+
"profile": inst.profile,
|
|
922
|
+
"mode": inst.mode,
|
|
923
|
+
"modules": list(inst.modules_enabled),
|
|
924
|
+
"files_count": len(inst.files),
|
|
925
|
+
"settings_count": len(inst.settings_entries),
|
|
926
|
+
"installed_at": inst.installed_at,
|
|
927
|
+
"updated_at": inst.updated_at,
|
|
928
|
+
}
|
|
929
|
+
)
|
|
930
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
if detected_scope_name:
|
|
934
|
+
console.print(f"[dim]Auto-detected scope: {detected_scope_name}[/dim]")
|
|
935
|
+
elif scope is None and not show_all:
|
|
936
|
+
location = display_path(str(anchor)) if anchor else display_path(cwd)
|
|
937
|
+
console.print(f"[dim]No extensions detected in {location}[/dim]")
|
|
938
|
+
console.print("[dim]Showing all scopes for this location:[/dim]")
|
|
939
|
+
|
|
940
|
+
for s in scopes:
|
|
941
|
+
try:
|
|
942
|
+
project_root = _resolve_project_root(s, anchor=effective_anchor)
|
|
943
|
+
project_path_str = str(project_root) if project_root else None
|
|
944
|
+
except NoClaudeDirectoryError:
|
|
945
|
+
project_path_str = None
|
|
946
|
+
|
|
947
|
+
installation = tracking.get_installation(s.value, project_path_str)
|
|
948
|
+
|
|
949
|
+
console.print(f"\n[bold]Scope: {s.value}[/bold]")
|
|
950
|
+
|
|
951
|
+
if installation is None:
|
|
952
|
+
if s == InstallScope.USER:
|
|
953
|
+
location = "~/.claude"
|
|
954
|
+
elif project_path_str:
|
|
955
|
+
location = project_path_str
|
|
956
|
+
else:
|
|
957
|
+
location = str(anchor) if anchor else cwd
|
|
958
|
+
console.print(f" [dim]Not enabled at {display_path(location)}[/dim]")
|
|
959
|
+
continue
|
|
960
|
+
|
|
961
|
+
console.print(f" Profile: {installation.profile}")
|
|
962
|
+
console.print(f" Mode: {installation.mode}")
|
|
963
|
+
console.print(f" Modules: {', '.join(installation.modules_enabled)}")
|
|
964
|
+
console.print(f" Files: {len(installation.files)}")
|
|
965
|
+
console.print(f" Settings: {len(installation.settings_entries)} entries")
|
|
966
|
+
console.print(f" Installed: {installation.installed_at}")
|
|
967
|
+
console.print(f" Updated: {installation.updated_at}")
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
inst_profile = InstallProfile(installation.profile)
|
|
971
|
+
gated = get_gated_skills(inst_profile)
|
|
972
|
+
if gated:
|
|
973
|
+
skill_list = ", ".join(f"/forge:{name}" for name, _ in gated)
|
|
974
|
+
required = gated[0][1].value
|
|
975
|
+
console.print(f" [dim]Gated: {skill_list} (needs --profile {required})[/dim]")
|
|
976
|
+
except ValueError:
|
|
977
|
+
pass
|
|
978
|
+
|
|
979
|
+
if installation.files and len(installation.files) <= 10:
|
|
980
|
+
console.print("\n [dim]Files:[/dim]")
|
|
981
|
+
for f in installation.files:
|
|
982
|
+
console.print(f" - {display_path(f.target_path)}")
|
|
983
|
+
|
|
984
|
+
if scope is None and not show_all and anchor is None:
|
|
985
|
+
local_installed = any(
|
|
986
|
+
tracking.get_installation(
|
|
987
|
+
s.value,
|
|
988
|
+
str(_resolve_project_root(s)) if s != InstallScope.USER else None,
|
|
989
|
+
)
|
|
990
|
+
for s in scopes
|
|
991
|
+
if s == InstallScope.USER or _can_resolve_project_root(s)
|
|
992
|
+
)
|
|
993
|
+
if not local_installed:
|
|
994
|
+
all_installations = tracking.list_installations()
|
|
995
|
+
if all_installations:
|
|
996
|
+
console.print(
|
|
997
|
+
f"\n[dim]Tip: {len(all_installations)} installation(s) exist elsewhere. "
|
|
998
|
+
"Use 'forge info' to see all.[/dim]"
|
|
999
|
+
)
|
|
1000
|
+
else:
|
|
1001
|
+
console.print("\n[dim]Tip: Run 'forge extension enable' to set up Forge.[/dim]")
|