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/backend.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Backend management CLI commands.
|
|
2
|
+
|
|
3
|
+
Provides commands to manage backend services (LiteLLM, etc.) that proxies depend on:
|
|
4
|
+
- forge backend list: List all backends
|
|
5
|
+
- forge backend create: Create backend config
|
|
6
|
+
- forge backend start: Start a backend instance
|
|
7
|
+
- forge backend stop: Stop a backend instance
|
|
8
|
+
- forge backend delete: Delete backend config or instance
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
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.backend import BackendManager
|
|
21
|
+
from forge.backend.adapters import get_adapter
|
|
22
|
+
from forge.backend.creation import create_backend_config, get_backend_config_path
|
|
23
|
+
from forge.backend.registry import BackendRegistryStore, is_pid_alive
|
|
24
|
+
from forge.core.paths import display_path, get_forge_home
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
28
|
+
def backend() -> None:
|
|
29
|
+
"""Manage backends (LiteLLM, etc.).
|
|
30
|
+
|
|
31
|
+
\b
|
|
32
|
+
Examples:
|
|
33
|
+
forge backend list # List backends
|
|
34
|
+
forge backend create litellm # Create backend config
|
|
35
|
+
forge backend start litellm -p 4000 # Start an instance
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@backend.command("list")
|
|
40
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
41
|
+
def list_cmd(as_json: bool) -> None:
|
|
42
|
+
"""List all backends."""
|
|
43
|
+
console = Console(width=200)
|
|
44
|
+
store = BackendRegistryStore()
|
|
45
|
+
|
|
46
|
+
backends = store.read().backends
|
|
47
|
+
|
|
48
|
+
if as_json:
|
|
49
|
+
import json
|
|
50
|
+
|
|
51
|
+
data = []
|
|
52
|
+
for backend_id, b in backends.items():
|
|
53
|
+
data.append(
|
|
54
|
+
{
|
|
55
|
+
"backend_id": b.backend_id,
|
|
56
|
+
"adapter_type": b.adapter_type,
|
|
57
|
+
"port": b.port,
|
|
58
|
+
"pid": b.pid,
|
|
59
|
+
"status": b.status,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if not backends:
|
|
66
|
+
console.print("No backends found.")
|
|
67
|
+
console.print("\n[dim]Tip: Run 'forge backend create litellm'.[/dim]")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
table = Table(title="Forge Backends")
|
|
71
|
+
table.add_column("BACKEND ID", style="cyan")
|
|
72
|
+
table.add_column("ADAPTER")
|
|
73
|
+
table.add_column("PORT", justify="right")
|
|
74
|
+
table.add_column("PID", justify="right")
|
|
75
|
+
table.add_column("STATUS")
|
|
76
|
+
|
|
77
|
+
for backend_id, backend in backends.items():
|
|
78
|
+
table.add_row(
|
|
79
|
+
backend_id,
|
|
80
|
+
backend.adapter_type,
|
|
81
|
+
str(backend.port),
|
|
82
|
+
str(backend.pid) if backend.pid else "-",
|
|
83
|
+
backend.status,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
console.print(table)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@backend.command("show")
|
|
90
|
+
@click.argument("backend_id")
|
|
91
|
+
@click.option("--raw", is_flag=True, help="Output raw config without syntax highlighting")
|
|
92
|
+
def show_cmd(backend_id: str, raw: bool) -> None:
|
|
93
|
+
"""Show backend details and configuration.
|
|
94
|
+
|
|
95
|
+
\b
|
|
96
|
+
Examples:
|
|
97
|
+
forge backend show litellm-4000
|
|
98
|
+
"""
|
|
99
|
+
console = Console(width=200)
|
|
100
|
+
store = BackendRegistryStore()
|
|
101
|
+
|
|
102
|
+
# Parse adapter type from backend_id (e.g., "litellm-4000" -> "litellm")
|
|
103
|
+
parts = backend_id.rsplit("-", 1)
|
|
104
|
+
adapter_type = parts[0] if len(parts) == 2 else backend_id
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
registry = store.read()
|
|
108
|
+
instance = registry.backends.get(backend_id)
|
|
109
|
+
if instance:
|
|
110
|
+
alive = instance.pid is not None and is_pid_alive(instance.pid)
|
|
111
|
+
status_color = "green" if alive else "yellow"
|
|
112
|
+
console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan]")
|
|
113
|
+
console.print(f"[bold]Adapter:[/bold] {instance.adapter_type}")
|
|
114
|
+
console.print(f"[bold]Port:[/bold] {instance.port}")
|
|
115
|
+
console.print(f"[bold]PID:[/bold] {instance.pid or '-'}")
|
|
116
|
+
console.print(
|
|
117
|
+
f"[bold]Status:[/bold] [{status_color}]{'healthy' if alive else 'not running'}[/{status_color}]"
|
|
118
|
+
)
|
|
119
|
+
if instance.created_at:
|
|
120
|
+
console.print(f"[bold]Started:[/bold] {instance.created_at}")
|
|
121
|
+
else:
|
|
122
|
+
console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan] [dim](not in registry)[/dim]")
|
|
123
|
+
except Exception:
|
|
124
|
+
console.print(f"[bold]Backend:[/bold] [cyan]{backend_id}[/cyan]")
|
|
125
|
+
|
|
126
|
+
log_file = get_forge_home() / "logs" / "backend" / f"{backend_id}.log"
|
|
127
|
+
if log_file.exists():
|
|
128
|
+
console.print(f"[bold]Log:[/bold] {display_path(log_file)}")
|
|
129
|
+
else:
|
|
130
|
+
log_file = (
|
|
131
|
+
get_forge_home() / "logs" / "backend" / f"{adapter_type}-{parts[1] if len(parts) == 2 else '4000'}.log"
|
|
132
|
+
)
|
|
133
|
+
if log_file.exists():
|
|
134
|
+
console.print(f"[bold]Log:[/bold] {display_path(log_file)}")
|
|
135
|
+
|
|
136
|
+
config_path = get_backend_config_path(adapter_type)
|
|
137
|
+
if config_path.exists():
|
|
138
|
+
content = config_path.read_text()
|
|
139
|
+
console.print(f"[bold]Config:[/bold] {display_path(config_path)}\n")
|
|
140
|
+
if raw:
|
|
141
|
+
console.print(content)
|
|
142
|
+
else:
|
|
143
|
+
from rich.syntax import Syntax
|
|
144
|
+
|
|
145
|
+
syntax = Syntax(content, "yaml", theme="monokai", line_numbers=True)
|
|
146
|
+
console.print(syntax)
|
|
147
|
+
else:
|
|
148
|
+
console.print(f"\n[dim]No config found for adapter '{adapter_type}'.[/dim]")
|
|
149
|
+
console.print(f"[dim]Tip: Run 'forge backend create {adapter_type}'.[/dim]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@backend.command("create")
|
|
153
|
+
@click.argument("adapter", type=click.Choice(["litellm"]))
|
|
154
|
+
@click.option(
|
|
155
|
+
"--config",
|
|
156
|
+
"-c",
|
|
157
|
+
type=Path,
|
|
158
|
+
help="Source config file (defaults to installed template)",
|
|
159
|
+
)
|
|
160
|
+
def create_cmd(adapter: str, config: Path | None) -> None:
|
|
161
|
+
"""Create a backend config (copy to installed location).
|
|
162
|
+
|
|
163
|
+
Config is shared by all instances of this adapter type.
|
|
164
|
+
"""
|
|
165
|
+
console = Console(width=200)
|
|
166
|
+
|
|
167
|
+
config_path = get_backend_config_path(adapter)
|
|
168
|
+
if config_path.exists():
|
|
169
|
+
console.print(f"[yellow]Backend config already exists:[/yellow] {display_path(config_path)}")
|
|
170
|
+
console.print("\n[dim]Start an instance with:[/dim]")
|
|
171
|
+
console.print(f" forge backend start {adapter} --port 4000")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
config_path = create_backend_config(
|
|
176
|
+
adapter_type=adapter,
|
|
177
|
+
source_config=config,
|
|
178
|
+
)
|
|
179
|
+
console.print(f"[green]Created[/green] backend config for '{adapter}'")
|
|
180
|
+
console.print(f" Config: {display_path(config_path)}")
|
|
181
|
+
console.print("\n[dim]Start an instance with:[/dim]")
|
|
182
|
+
console.print(f" forge backend start {adapter} --port 4000")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@backend.command("start")
|
|
189
|
+
@click.argument("adapter", type=click.Choice(["litellm"]))
|
|
190
|
+
@click.option("--port", "-p", type=int, required=True, help="Port number")
|
|
191
|
+
def start_cmd(adapter: str, port: int) -> None:
|
|
192
|
+
"""Start a backend instance."""
|
|
193
|
+
console = Console(width=200)
|
|
194
|
+
|
|
195
|
+
config_path = get_backend_config_path(adapter)
|
|
196
|
+
if not config_path.exists():
|
|
197
|
+
console.print(f"[red]Error:[/red] Backend config not found for '{adapter}'")
|
|
198
|
+
console.print("\n[dim]Create it first:[/dim]")
|
|
199
|
+
console.print(f" forge backend create {adapter}")
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
backend_id = f"{adapter}-{port}"
|
|
203
|
+
store = BackendRegistryStore()
|
|
204
|
+
manager = BackendManager(store)
|
|
205
|
+
manager.register_adapter(adapter, get_adapter(adapter))
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = manager.ensure_backend(backend_id, adapter, port)
|
|
209
|
+
if result.source == "start":
|
|
210
|
+
console.print(f"[green]Started[/green] backend '{backend_id}' on port {port} (pid {result.instance.pid})")
|
|
211
|
+
else:
|
|
212
|
+
console.print(f"Backend '{backend_id}' already running on port {port}")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@backend.command("stop")
|
|
219
|
+
@click.argument("adapter", type=click.Choice(["litellm"]))
|
|
220
|
+
@click.option("--port", "-p", type=int, required=True, help="Port number")
|
|
221
|
+
def stop_cmd(adapter: str, port: int) -> None:
|
|
222
|
+
"""Stop a backend instance."""
|
|
223
|
+
console = Console(width=200)
|
|
224
|
+
backend_id = f"{adapter}-{port}"
|
|
225
|
+
|
|
226
|
+
store = BackendRegistryStore()
|
|
227
|
+
manager = BackendManager(store)
|
|
228
|
+
manager.register_adapter(adapter, get_adapter(adapter))
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
manager.stop_backend(backend_id)
|
|
232
|
+
console.print(f"[green]Stopped[/green] backend '{backend_id}'")
|
|
233
|
+
except Exception as e:
|
|
234
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@backend.command("delete")
|
|
239
|
+
@click.argument("adapter", type=click.Choice(["litellm"]))
|
|
240
|
+
@click.option(
|
|
241
|
+
"--port",
|
|
242
|
+
"-p",
|
|
243
|
+
type=int,
|
|
244
|
+
help="Delete specific instance (if not specified, deletes config)",
|
|
245
|
+
)
|
|
246
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
247
|
+
@click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
|
|
248
|
+
def delete_cmd(adapter: str, port: int | None, yes: bool, force: bool) -> None:
|
|
249
|
+
"""Delete a backend instance or config.
|
|
250
|
+
|
|
251
|
+
Without --port: Deletes the backend config (stops all instances first).
|
|
252
|
+
With --port: Stops and unregisters specific instance (keeps config).
|
|
253
|
+
"""
|
|
254
|
+
import shutil
|
|
255
|
+
|
|
256
|
+
yes = yes or force
|
|
257
|
+
console = Console(width=200)
|
|
258
|
+
|
|
259
|
+
if port is not None:
|
|
260
|
+
backend_id = f"{adapter}-{port}"
|
|
261
|
+
if not yes and not click.confirm(f"Stop backend instance '{backend_id}'?"):
|
|
262
|
+
console.print("Cancelled.")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
stop_cmd.callback(adapter, port) # type: ignore[misc] # click.Command.callback is Optional[Callable]; always set here
|
|
267
|
+
console.print(f"[green]Stopped[/green] backend instance '{backend_id}'")
|
|
268
|
+
except Exception as e:
|
|
269
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
else:
|
|
272
|
+
backend_dir = get_forge_home() / "backends" / adapter
|
|
273
|
+
if not backend_dir.exists():
|
|
274
|
+
console.print(f"[red]Error:[/red] Backend config not found for '{adapter}'")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
if not yes and not click.confirm(f"Delete backend config for '{adapter}' (stops all instances)?"):
|
|
278
|
+
console.print("Cancelled.")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
store = BackendRegistryStore()
|
|
282
|
+
registry = store.read()
|
|
283
|
+
stopped = []
|
|
284
|
+
for backend_id in list(registry.backends.keys()):
|
|
285
|
+
if backend_id.startswith(f"{adapter}-"):
|
|
286
|
+
try:
|
|
287
|
+
# Use rsplit to handle adapter names with hyphens (e.g., "some-adapter-4000")
|
|
288
|
+
port_str = backend_id.rsplit("-", 1)[1]
|
|
289
|
+
stop_cmd.callback(adapter, int(port_str)) # type: ignore[misc] # click.Command.callback is Optional[Callable]; always set here
|
|
290
|
+
stopped.append(backend_id)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
if stopped:
|
|
295
|
+
console.print(f"Stopped instances: {', '.join(stopped)}")
|
|
296
|
+
|
|
297
|
+
shutil.rmtree(backend_dir)
|
|
298
|
+
console.print(f"[green]Deleted[/green] backend config for '{adapter}'")
|
forge/cli/claude.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Claude Code management commands.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- forge claude start --proxy <id> - Start Claude with specific proxy
|
|
5
|
+
- forge claude start --no-proxy - Start Claude without proxy
|
|
6
|
+
- forge claude preset - Manage settings preset
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import httpx
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from forge.core.paths import display_path
|
|
22
|
+
from forge.proxy.proxies import (
|
|
23
|
+
ProxyRegistryCorruptedError,
|
|
24
|
+
ProxyRegistryStore,
|
|
25
|
+
ProxyResolutionError,
|
|
26
|
+
resolve_proxy,
|
|
27
|
+
)
|
|
28
|
+
from forge.session.direct_model import apply_direct_model_env
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default_context_limit() -> int:
|
|
35
|
+
from forge.runtime_config import get_runtime_config
|
|
36
|
+
|
|
37
|
+
return get_runtime_config().context_limit
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_context_limit_for_proxy(proxy_id: str) -> int:
|
|
41
|
+
"""Compute context limit from proxy's default tier model.
|
|
42
|
+
|
|
43
|
+
Deterministic: uses the specific proxy_id to look up the exact model config,
|
|
44
|
+
unlike the heuristic _get_context_limit_for_template() in session.py which
|
|
45
|
+
picks the first matching template.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from forge.config.loader import load_proxy_instance_config
|
|
49
|
+
from forge.core.models import get_context_window_tokens
|
|
50
|
+
|
|
51
|
+
proxy_config = load_proxy_instance_config(proxy_id)
|
|
52
|
+
if proxy_config is None:
|
|
53
|
+
logger.debug(f"No proxy config found for {proxy_id}, using default context limit")
|
|
54
|
+
return _default_context_limit()
|
|
55
|
+
|
|
56
|
+
tier = proxy_config.default_tier or "sonnet"
|
|
57
|
+
|
|
58
|
+
model = proxy_config.tiers.get(tier)
|
|
59
|
+
if not model:
|
|
60
|
+
logger.debug(f"No model found for tier {tier}, using default context limit")
|
|
61
|
+
return _default_context_limit()
|
|
62
|
+
|
|
63
|
+
context_limit = get_context_window_tokens(model)
|
|
64
|
+
logger.debug(f"Computed context limit {context_limit} for model {model} (tier {tier})")
|
|
65
|
+
return context_limit
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.debug(f"Failed to compute context limit: {e}, using default")
|
|
69
|
+
return _default_context_limit()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _healthcheck_proxy(*, base_url: str, expected_template: str, expected_proxy_id: str) -> None:
|
|
73
|
+
"""Validate proxy is reachable and matches proxy identity."""
|
|
74
|
+
|
|
75
|
+
url = base_url.rstrip("/") + "/"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
response = httpx.get(url, timeout=2.0)
|
|
79
|
+
except httpx.ConnectError:
|
|
80
|
+
raise ValueError(f"proxy is not running (connection refused at {url})")
|
|
81
|
+
except httpx.RequestError as e:
|
|
82
|
+
raise ValueError(f"proxy healthcheck failed at {url}: {e}")
|
|
83
|
+
|
|
84
|
+
if response.status_code != 200:
|
|
85
|
+
raise ValueError(f"proxy healthcheck failed at {url}: status {response.status_code}")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
data = response.json()
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
raise ValueError(f"proxy healthcheck failed at {url}: invalid JSON: {e}")
|
|
91
|
+
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise ValueError(f"proxy healthcheck failed at {url}: expected JSON object")
|
|
94
|
+
|
|
95
|
+
if data.get("is_proxy") is not True:
|
|
96
|
+
raise ValueError(f"proxy healthcheck failed at {url}: is_proxy is not true")
|
|
97
|
+
|
|
98
|
+
template = data.get("template")
|
|
99
|
+
if template != expected_template:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"proxy healthcheck failed at {url}: template mismatch (expected '{expected_template}', got '{template}')"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
proxy_block = data.get("proxy")
|
|
105
|
+
if not isinstance(proxy_block, dict):
|
|
106
|
+
raise ValueError(f"proxy healthcheck failed at {url}: missing proxy block")
|
|
107
|
+
|
|
108
|
+
actual_proxy_id = proxy_block.get("proxy_id")
|
|
109
|
+
if actual_proxy_id != expected_proxy_id:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"proxy healthcheck failed at {url}: proxy_id mismatch (expected '{expected_proxy_id}', got '{actual_proxy_id}')"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- Group and Commands ---
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.group(
|
|
119
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
120
|
+
)
|
|
121
|
+
def claude() -> None:
|
|
122
|
+
"""Start and manage Claude Code.
|
|
123
|
+
|
|
124
|
+
\b
|
|
125
|
+
Examples:
|
|
126
|
+
forge claude start --proxy my-proxy # Start with specific proxy
|
|
127
|
+
forge claude start --no-proxy # Start without proxy (direct to Anthropic)
|
|
128
|
+
"""
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_bare_launch_env(
|
|
133
|
+
*,
|
|
134
|
+
base_url: str | None,
|
|
135
|
+
template: str | None,
|
|
136
|
+
context_limit: int | None,
|
|
137
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
138
|
+
"""Build environment for bare Claude launch (no session state).
|
|
139
|
+
|
|
140
|
+
Returns (env_vars_to_set, env_vars_to_unset). Always scrubs session
|
|
141
|
+
identity vars so a nested ``forge claude start`` never inherits a
|
|
142
|
+
parent session.
|
|
143
|
+
"""
|
|
144
|
+
env_vars: dict[str, str] = {}
|
|
145
|
+
unset_vars: list[str] = ["FORGE_SESSION", "FORGE_FORK_NAME", "FORGE_PARENT_SESSION"]
|
|
146
|
+
|
|
147
|
+
if base_url is None:
|
|
148
|
+
# Direct mode: don't touch CLAUDE_CODE_AUTO_COMPACT_WINDOW — it's a
|
|
149
|
+
# native CC env var the user may have set. Only scrub Forge-managed vars.
|
|
150
|
+
unset_vars.extend(["ANTHROPIC_BASE_URL", "ACTIVE_TEMPLATE"])
|
|
151
|
+
else:
|
|
152
|
+
env_vars["ANTHROPIC_BASE_URL"] = base_url
|
|
153
|
+
if context_limit is not None:
|
|
154
|
+
env_vars["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(context_limit)
|
|
155
|
+
if template:
|
|
156
|
+
env_vars["ACTIVE_TEMPLATE"] = template
|
|
157
|
+
else:
|
|
158
|
+
unset_vars.append("ACTIVE_TEMPLATE")
|
|
159
|
+
|
|
160
|
+
return env_vars, unset_vars
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@claude.command("start")
|
|
164
|
+
@click.option(
|
|
165
|
+
"--proxy",
|
|
166
|
+
"proxy_id",
|
|
167
|
+
type=str,
|
|
168
|
+
default=None,
|
|
169
|
+
help="Proxy to use (proxy_id or template name)",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--no-proxy",
|
|
173
|
+
"direct",
|
|
174
|
+
is_flag=True,
|
|
175
|
+
default=False,
|
|
176
|
+
help="Bypass the proxy and talk to Anthropic directly",
|
|
177
|
+
)
|
|
178
|
+
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
|
|
179
|
+
def start_cmd(
|
|
180
|
+
proxy_id: str | None,
|
|
181
|
+
direct: bool,
|
|
182
|
+
claude_args: tuple[str, ...],
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Start Claude Code with proxy routing or direct to Anthropic.
|
|
185
|
+
|
|
186
|
+
Bare launcher: no session state, no FORGE_SESSION. Use
|
|
187
|
+
``forge session start`` for managed sessions with lifecycle tracking.
|
|
188
|
+
|
|
189
|
+
\b
|
|
190
|
+
Examples:
|
|
191
|
+
forge claude start --proxy my-proxy
|
|
192
|
+
forge claude start --no-proxy
|
|
193
|
+
forge claude start --proxy my-proxy -- --debug
|
|
194
|
+
"""
|
|
195
|
+
if direct and proxy_id:
|
|
196
|
+
click.echo("Error: --no-proxy and --proxy are mutually exclusive")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
if not direct and not proxy_id:
|
|
199
|
+
click.echo("Error: one of --proxy or --no-proxy is required")
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
from forge.session.claude.invoke import invoke_claude
|
|
203
|
+
|
|
204
|
+
# Resolve proxy to template + base_url
|
|
205
|
+
template: str | None = None
|
|
206
|
+
base_url: str | None = None
|
|
207
|
+
context_limit: int | None = None
|
|
208
|
+
proxy_display: str | None = None
|
|
209
|
+
|
|
210
|
+
if proxy_id:
|
|
211
|
+
proxy_store = ProxyRegistryStore()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
registry = proxy_store.read()
|
|
215
|
+
except ProxyRegistryCorruptedError as e:
|
|
216
|
+
click.echo(f"Error: {e}")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
entry = resolve_proxy(registry, proxy_id)
|
|
221
|
+
except ProxyResolutionError as e:
|
|
222
|
+
click.echo(f"Error: {e}")
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
_healthcheck_proxy(
|
|
227
|
+
base_url=entry.base_url,
|
|
228
|
+
expected_template=entry.template,
|
|
229
|
+
expected_proxy_id=entry.proxy_id,
|
|
230
|
+
)
|
|
231
|
+
except ValueError as e:
|
|
232
|
+
click.echo(f"Error: {e}")
|
|
233
|
+
if "not running" in str(e):
|
|
234
|
+
click.echo(f"Tip: Run 'forge proxy start {entry.proxy_id}' to start it.")
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
template = entry.template
|
|
238
|
+
base_url = entry.base_url
|
|
239
|
+
context_limit = _get_context_limit_for_proxy(entry.proxy_id)
|
|
240
|
+
proxy_display = entry.proxy_id
|
|
241
|
+
|
|
242
|
+
env_vars, unset_vars = _build_bare_launch_env(
|
|
243
|
+
base_url=base_url,
|
|
244
|
+
template=template,
|
|
245
|
+
context_limit=context_limit,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if direct:
|
|
249
|
+
from forge.runtime_config import get_default_direct_model
|
|
250
|
+
|
|
251
|
+
direct_model = get_default_direct_model()
|
|
252
|
+
error = apply_direct_model_env(env_vars, direct_model)
|
|
253
|
+
if error:
|
|
254
|
+
click.echo(f"Error: {error}")
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
if proxy_display:
|
|
258
|
+
console.print(f"Starting Claude with proxy [green]{proxy_display}[/green] ({template})")
|
|
259
|
+
else:
|
|
260
|
+
console.print("Starting Claude [green]direct[/green] (no proxy)")
|
|
261
|
+
|
|
262
|
+
from forge.cli.session_addendum import (
|
|
263
|
+
resolve_addendum_content_for_proxy,
|
|
264
|
+
write_bare_addendum,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
addendum_content = resolve_addendum_content_for_proxy(proxy_display)
|
|
268
|
+
addendum_path: Path | None = None
|
|
269
|
+
if addendum_content:
|
|
270
|
+
addendum_path = write_bare_addendum(addendum_content)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
sys.exit(
|
|
274
|
+
invoke_claude(
|
|
275
|
+
model=None,
|
|
276
|
+
system_prompt_file=str(addendum_path) if addendum_path else None,
|
|
277
|
+
env_vars=env_vars,
|
|
278
|
+
unset_env_vars=unset_vars,
|
|
279
|
+
extra_args=list(claude_args) if claude_args else None,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
finally:
|
|
283
|
+
if addendum_path:
|
|
284
|
+
addendum_path.unlink(missing_ok=True)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --- Preset subgroup ---
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@claude.group("preset")
|
|
291
|
+
def preset() -> None:
|
|
292
|
+
"""Manage Claude Code settings preset.
|
|
293
|
+
|
|
294
|
+
\b
|
|
295
|
+
The preset (~/.forge/claude.preset.json) controls what settings
|
|
296
|
+
Forge merges into Claude Code's settings.json on enable/sync.
|
|
297
|
+
|
|
298
|
+
\b
|
|
299
|
+
Examples:
|
|
300
|
+
forge claude preset show # Show current preset
|
|
301
|
+
forge claude preset edit # Open in $EDITOR
|
|
302
|
+
forge claude preset reset # Reset to built-in defaults
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@preset.command("show")
|
|
307
|
+
@click.option("--raw", is_flag=True, help="Output raw JSON without syntax highlighting")
|
|
308
|
+
def preset_show(raw: bool = False) -> None:
|
|
309
|
+
"""Show current Claude Code settings preset."""
|
|
310
|
+
from rich.syntax import Syntax
|
|
311
|
+
|
|
312
|
+
from forge.install.preset import ensure_preset, get_preset_path
|
|
313
|
+
|
|
314
|
+
preset_path = get_preset_path()
|
|
315
|
+
ensure_preset()
|
|
316
|
+
|
|
317
|
+
content = preset_path.read_text(encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
if raw:
|
|
320
|
+
console.print(content, end="")
|
|
321
|
+
else:
|
|
322
|
+
console.print("[bold]Claude Code Settings Preset[/bold]")
|
|
323
|
+
console.print(f"[bold]Path:[/bold] {display_path(preset_path)}")
|
|
324
|
+
console.print()
|
|
325
|
+
syntax = Syntax(content, "json", theme="monokai")
|
|
326
|
+
console.print(syntax)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@preset.command("edit")
|
|
330
|
+
def preset_edit() -> None:
|
|
331
|
+
"""Open settings preset in $EDITOR.
|
|
332
|
+
|
|
333
|
+
Creates the file with built-in defaults if it doesn't exist.
|
|
334
|
+
Validates JSON before saving.
|
|
335
|
+
"""
|
|
336
|
+
import json
|
|
337
|
+
import shutil
|
|
338
|
+
import tempfile
|
|
339
|
+
|
|
340
|
+
from forge.install.preset import ensure_preset, get_preset_path
|
|
341
|
+
|
|
342
|
+
preset_path = get_preset_path()
|
|
343
|
+
ensure_preset()
|
|
344
|
+
|
|
345
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
346
|
+
if not shutil.which(editor):
|
|
347
|
+
console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
|
|
348
|
+
sys.exit(1)
|
|
349
|
+
|
|
350
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
|
351
|
+
tmp.write(preset_path.read_text(encoding="utf-8"))
|
|
352
|
+
tmp_path = Path(tmp.name)
|
|
353
|
+
|
|
354
|
+
success = False
|
|
355
|
+
try:
|
|
356
|
+
result = subprocess.run([editor, str(tmp_path)])
|
|
357
|
+
if result.returncode != 0:
|
|
358
|
+
console.print(f"[red]Error:[/red] Editor exited with code {result.returncode}")
|
|
359
|
+
console.print(f"Your changes are saved at: {display_path(tmp_path)}")
|
|
360
|
+
sys.exit(1)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
with open(tmp_path, encoding="utf-8") as f:
|
|
364
|
+
edited_data = json.load(f)
|
|
365
|
+
except json.JSONDecodeError as e:
|
|
366
|
+
console.print(f"[red]Error:[/red] Invalid JSON: {e}")
|
|
367
|
+
console.print(f"Your changes are saved at: {display_path(tmp_path)}")
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
|
|
370
|
+
if not isinstance(edited_data, dict):
|
|
371
|
+
console.print("[red]Error:[/red] Preset must be a JSON object")
|
|
372
|
+
console.print(f"Your changes are saved at: {display_path(tmp_path)}")
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
|
|
375
|
+
from forge.core.state import atomic_write_text
|
|
376
|
+
|
|
377
|
+
content = json.dumps(edited_data, indent=2) + "\n"
|
|
378
|
+
atomic_write_text(preset_path, content)
|
|
379
|
+
os.chmod(str(preset_path), 0o600)
|
|
380
|
+
|
|
381
|
+
success = True
|
|
382
|
+
console.print("[green]Updated[/green] settings preset")
|
|
383
|
+
|
|
384
|
+
finally:
|
|
385
|
+
if success and tmp_path.exists():
|
|
386
|
+
try:
|
|
387
|
+
tmp_path.unlink()
|
|
388
|
+
except OSError:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@preset.command("reset")
|
|
393
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
394
|
+
@click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
|
|
395
|
+
def preset_reset(yes: bool, force: bool) -> None:
|
|
396
|
+
"""Reset settings preset to built-in defaults."""
|
|
397
|
+
from forge.core.state import atomic_write_text
|
|
398
|
+
from forge.install.preset import get_builtin_preset_json, get_preset_path
|
|
399
|
+
|
|
400
|
+
yes = yes or force
|
|
401
|
+
preset_path = get_preset_path()
|
|
402
|
+
|
|
403
|
+
if not yes:
|
|
404
|
+
if not click.confirm("Reset preset to built-in defaults?"):
|
|
405
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
preset_path.parent.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
atomic_write_text(preset_path, get_builtin_preset_json())
|
|
410
|
+
os.chmod(str(preset_path), 0o600)
|
|
411
|
+
console.print("[green]Reset[/green] preset to built-in defaults")
|