forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/sessions.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Saved conversations in ~/.forge/sessions/<id>.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import secrets
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from .config import HOME_DIR
|
|
12
|
+
|
|
13
|
+
SESSION_DIR = HOME_DIR / "sessions"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now() -> str:
|
|
17
|
+
return dt.datetime.now().isoformat(timespec="seconds")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Session:
|
|
22
|
+
id: str = field(default_factory=lambda: dt.datetime.now().strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(2))
|
|
23
|
+
title: str = ""
|
|
24
|
+
cwd: str = field(default_factory=lambda: os.getcwd())
|
|
25
|
+
created: str = field(default_factory=_now)
|
|
26
|
+
updated: str = field(default_factory=_now)
|
|
27
|
+
messages: list[dict] = field(default_factory=list) # everything after the system prompt
|
|
28
|
+
usage_in: int = 0
|
|
29
|
+
usage_out: int = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def path(self) -> pathlib.Path:
|
|
33
|
+
return SESSION_DIR / f"{self.id}.json"
|
|
34
|
+
|
|
35
|
+
def save(self) -> None:
|
|
36
|
+
if not any(m.get("role") == "user" for m in self.messages):
|
|
37
|
+
return # don't litter the list with empty sessions
|
|
38
|
+
if not self.title:
|
|
39
|
+
first = next(m["content"] for m in self.messages if m.get("role") == "user")
|
|
40
|
+
self.title = " ".join(first.split())[:60]
|
|
41
|
+
self.updated = _now()
|
|
42
|
+
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
tmp = self.path.with_suffix(".tmp")
|
|
44
|
+
tmp.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
45
|
+
os.replace(tmp, self.path)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def load(cls, sid: str) -> "Session":
|
|
49
|
+
data = json.loads((SESSION_DIR / f"{sid}.json").read_text(encoding="utf-8"))
|
|
50
|
+
return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_sessions() -> list[dict]:
|
|
54
|
+
"""Newest first. Each item: id, title, cwd, updated, count."""
|
|
55
|
+
out = []
|
|
56
|
+
if not SESSION_DIR.is_dir():
|
|
57
|
+
return out
|
|
58
|
+
for f in SESSION_DIR.glob("*.json"):
|
|
59
|
+
try:
|
|
60
|
+
d = json.loads(f.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
continue
|
|
63
|
+
out.append({"id": d.get("id", f.stem), "title": d.get("title", ""), "cwd": d.get("cwd", ""),
|
|
64
|
+
"updated": d.get("updated", ""),
|
|
65
|
+
"count": sum(1 for m in d.get("messages", []) if m.get("role") == "user")})
|
|
66
|
+
return sorted(out, key=lambda s: s["updated"], reverse=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find(ref: str) -> str | None:
|
|
70
|
+
"""Resolve '3' (row in /sessions), a full id, or an id prefix."""
|
|
71
|
+
items = list_sessions()
|
|
72
|
+
if ref.isdigit() and 1 <= int(ref) <= len(items):
|
|
73
|
+
return items[int(ref) - 1]["id"]
|
|
74
|
+
hits = [s["id"] for s in items if s["id"].startswith(ref)]
|
|
75
|
+
return hits[0] if len(hits) == 1 else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def latest_for(cwd: str) -> str | None:
|
|
79
|
+
for s in list_sessions():
|
|
80
|
+
if os.path.normcase(s["cwd"]) == os.path.normcase(cwd):
|
|
81
|
+
return s["id"]
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def delete(sid: str) -> None:
|
|
86
|
+
(SESSION_DIR / f"{sid}.json").unlink(missing_ok=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ago(stamp: str) -> str:
|
|
90
|
+
try:
|
|
91
|
+
secs = (dt.datetime.now() - dt.datetime.fromisoformat(stamp)).total_seconds()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return stamp
|
|
94
|
+
for unit, n in (("d", 86400), ("h", 3600), ("m", 60)):
|
|
95
|
+
if secs >= n:
|
|
96
|
+
return f"{int(secs // n)}{unit} ago"
|
|
97
|
+
return "just now"
|
forgecli/skills.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Skills: reusable instructions stored as Markdown files.
|
|
2
|
+
|
|
3
|
+
A skill is either <dir>/<name>/SKILL.md or <dir>/<name>.md , with optional
|
|
4
|
+
front matter:
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
name: review
|
|
8
|
+
description: Review uncommitted changes for bugs before committing
|
|
9
|
+
---
|
|
10
|
+
Instructions for the model...
|
|
11
|
+
|
|
12
|
+
Searched (later wins on name clashes):
|
|
13
|
+
0. claude every skill and plugin command Claude Code has installed on this machine
|
|
14
|
+
(~/.claude/skills, installed plugins, synced plugins) - read in place, never copied
|
|
15
|
+
1. bundled <forge-cli>/skills/library - engineering skills that ship with Forge
|
|
16
|
+
(agent-skills and wondelai, MIT), so they work with no Claude Code and no sync
|
|
17
|
+
2. built-in <forge-cli>/skills
|
|
18
|
+
3. user ~/.forge/skills
|
|
19
|
+
4. project <cwd>/.forge/skills
|
|
20
|
+
|
|
21
|
+
There can be a couple of hundred skills, so only Forge's own core ones are listed to the model;
|
|
22
|
+
the rest are found on demand with the find_skill tool (keeps every request small).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime as dt
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import pathlib
|
|
30
|
+
import sys
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
from .config import APP_DIR, HOME_DIR
|
|
36
|
+
|
|
37
|
+
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,80}$")
|
|
38
|
+
CLAUDE_DIR = pathlib.Path(os.environ.get("CLAUDE_CONFIG_DIR") or pathlib.Path.home() / ".claude")
|
|
39
|
+
MAX_BODY = 12_000 # characters of a skill sent at once (fits small free-tier limits); rest via read_file
|
|
40
|
+
|
|
41
|
+
# Claude Code tool names that skills mention, and what to use in Forge instead.
|
|
42
|
+
TOOL_MAP = ("Tool names in this skill are Claude Code's. In Forge use: Bash/PowerShell → run_shell, "
|
|
43
|
+
"Read → read_file, Write → write_file, Edit/MultiEdit → edit_file, Grep → grep, "
|
|
44
|
+
"Glob → glob, WebFetch → web_fetch, WebSearch → web_search, TodoWrite/TaskCreate → todo, "
|
|
45
|
+
"AskUserQuestion → ask_user, Skill → use_skill (find_skill to look one up), "
|
|
46
|
+
"Agent/Task → delegate or run_parallel when enabled (otherwise do the steps yourself). "
|
|
47
|
+
"For MCP connectors, call mcp_search and then use only the exact loaded mcp__… tool schema. "
|
|
48
|
+
"For APIs, use http_request or import an OpenAPI spec; do not invent endpoints or parameters.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Skill:
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
path: pathlib.Path
|
|
56
|
+
source: str # claude | built-in | user | project
|
|
57
|
+
origin: str = "" # e.g. the Claude plugin it came from
|
|
58
|
+
needs: str = "" # connector the skill relies on that Forge doesn't have
|
|
59
|
+
|
|
60
|
+
def body(self) -> str:
|
|
61
|
+
text = _split(self.path.read_text(encoding="utf-8", errors="replace"))[1].strip()
|
|
62
|
+
base = str(self.path.parent)
|
|
63
|
+
root = str(_plugin_root(self.path) or self.path.parent)
|
|
64
|
+
for k, v in (("${CLAUDE_SKILL_DIR}", base), ("${CLAUDE_PLUGIN_ROOT}", root),
|
|
65
|
+
("{baseDir}", base), ("$SKILL_DIR", base)):
|
|
66
|
+
text = text.replace(k, v)
|
|
67
|
+
if len(text) > MAX_BODY:
|
|
68
|
+
text = (text[:MAX_BODY] + f"\n\n… [skill continues: read_file {self.path} "
|
|
69
|
+
f"with offset to read the rest]")
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
def full(self) -> str:
|
|
73
|
+
"""What use_skill returns: where the skill lives, how to map tools, then its text."""
|
|
74
|
+
head = [f"Skill '{self.name}'" + (f" (from Claude Code: {self.origin})" if self.origin else ""),
|
|
75
|
+
f"Its folder: {self.path.parent} — files it mentions (references/, scripts/, "
|
|
76
|
+
f"templates) are there; read them with read_file, run scripts with run_shell."]
|
|
77
|
+
if self.source in ("claude", "bundled"):
|
|
78
|
+
head.append(TOOL_MAP)
|
|
79
|
+
if self.needs:
|
|
80
|
+
head.append(f"Note: this skill comes from the {self.needs} plugin, which may rely on online "
|
|
81
|
+
"service connectors. Search configured MCP servers with mcp_search; if no matching "
|
|
82
|
+
"tool is available, do the parts that work and report what needs that service.")
|
|
83
|
+
return "\n".join(head) + "\n\n" + self.body()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _split(text: str) -> tuple[dict, str]:
|
|
87
|
+
"""Front matter → dict. Handles one-line values and YAML block values (> or |)."""
|
|
88
|
+
meta: dict[str, str] = {}
|
|
89
|
+
if text.startswith(""):
|
|
90
|
+
text = text[1:]
|
|
91
|
+
if not text.startswith("---"):
|
|
92
|
+
return meta, text
|
|
93
|
+
end = text.find("\n---", 3)
|
|
94
|
+
if end == -1:
|
|
95
|
+
return meta, text
|
|
96
|
+
key = None
|
|
97
|
+
for line in text[3:end].splitlines():
|
|
98
|
+
if key and (line.startswith((" ", "\t")) or not line.strip()):
|
|
99
|
+
meta[key] = (meta[key] + " " + line.strip()).strip()
|
|
100
|
+
continue
|
|
101
|
+
key = None
|
|
102
|
+
if ":" in line and not line.startswith((" ", "\t", "-")):
|
|
103
|
+
k, v = line.split(":", 1)
|
|
104
|
+
k, v = k.strip().lower(), v.strip()
|
|
105
|
+
if v in (">", "|", ">-", "|-", ""):
|
|
106
|
+
meta[k], key = "", k
|
|
107
|
+
else:
|
|
108
|
+
meta[k] = v.strip('"').strip("'")
|
|
109
|
+
return meta, text[end + 4:]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _plugin_root(path: pathlib.Path) -> pathlib.Path | None:
|
|
113
|
+
for parent in path.parents:
|
|
114
|
+
if (parent / ".claude-plugin").is_dir():
|
|
115
|
+
return parent
|
|
116
|
+
if parent in (CLAUDE_DIR, LIBRARY):
|
|
117
|
+
break
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Forge's own copy of Claude Code's skills, so they keep working without Claude Code.
|
|
122
|
+
LIBRARY = HOME_DIR / "library"
|
|
123
|
+
MANIFEST = "manifest.json"
|
|
124
|
+
_SKIP = shutil.ignore_patterns(".git", "node_modules", "__pycache__", ".venv", "venv", "*.pyc")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def synced_info() -> dict:
|
|
128
|
+
try:
|
|
129
|
+
return json.loads((LIBRARY / MANIFEST).read_text(encoding="utf-8"))
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sync() -> dict:
|
|
135
|
+
"""Copy every skill Claude Code has (with its scripts and reference files) into ~/.forge/library.
|
|
136
|
+
Built in a temp folder and swapped in, so a failed copy never leaves a half library."""
|
|
137
|
+
sources = _claude_sources()
|
|
138
|
+
if not sources:
|
|
139
|
+
raise RuntimeError(f"no Claude Code skills found in {CLAUDE_DIR}")
|
|
140
|
+
tmp = LIBRARY.with_name("library.new")
|
|
141
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
142
|
+
tmp.mkdir(parents=True)
|
|
143
|
+
plugins: dict[str, pathlib.Path] = {}
|
|
144
|
+
for d, plugin in sources:
|
|
145
|
+
if plugin:
|
|
146
|
+
plugins.setdefault(plugin, _plugin_root(d) or d.parent)
|
|
147
|
+
if (CLAUDE_DIR / "skills").is_dir():
|
|
148
|
+
shutil.copytree(CLAUDE_DIR / "skills", tmp / "skills", ignore=_SKIP)
|
|
149
|
+
for name, root in plugins.items():
|
|
150
|
+
shutil.copytree(root, tmp / "plugins" / name, ignore=_SKIP, dirs_exist_ok=True)
|
|
151
|
+
info = {"synced": dt.datetime.now().isoformat(timespec="seconds"), "from": str(CLAUDE_DIR),
|
|
152
|
+
"plugins": sorted(plugins)}
|
|
153
|
+
(tmp / MANIFEST).write_text(json.dumps(info, indent=1), encoding="utf-8")
|
|
154
|
+
old = LIBRARY.with_name("library.old")
|
|
155
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
156
|
+
if LIBRARY.exists():
|
|
157
|
+
LIBRARY.rename(old)
|
|
158
|
+
tmp.rename(LIBRARY)
|
|
159
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
160
|
+
return info
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _library_sources() -> list[tuple[pathlib.Path, str]]:
|
|
164
|
+
"""Same shape as _claude_sources(), but from Forge's own copy."""
|
|
165
|
+
out: list[tuple[pathlib.Path, str]] = []
|
|
166
|
+
skills = LIBRARY / "skills"
|
|
167
|
+
if skills.is_dir():
|
|
168
|
+
out.append((skills, ""))
|
|
169
|
+
if (skills / "synced").is_dir():
|
|
170
|
+
out += [(d, "") for d in (skills / "synced").iterdir() if d.is_dir()]
|
|
171
|
+
plugins = LIBRARY / "plugins"
|
|
172
|
+
if plugins.is_dir():
|
|
173
|
+
for p in sorted(plugins.iterdir()):
|
|
174
|
+
for sub in ("skills", "commands"):
|
|
175
|
+
if (p / sub).is_dir():
|
|
176
|
+
out.append((p / sub, p.name))
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _sources() -> list[tuple[pathlib.Path, str]]:
|
|
181
|
+
"""Forge's copy when there is one (made automatically the first time Claude Code's skills are seen)."""
|
|
182
|
+
if not (LIBRARY / MANIFEST).is_file() and CLAUDE_DIR.is_dir():
|
|
183
|
+
try:
|
|
184
|
+
sync()
|
|
185
|
+
except Exception: # can't copy? read them in place this time
|
|
186
|
+
return _claude_sources()
|
|
187
|
+
return _library_sources() if (LIBRARY / MANIFEST).is_file() else _claude_sources()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _needs(root: pathlib.Path | None, plugin: str) -> str:
|
|
191
|
+
if root and ((root / ".mcp.json").is_file() or (root / "mcp.json").is_file()):
|
|
192
|
+
return plugin or root.name.split("~")[0]
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _claude_sources() -> list[tuple[pathlib.Path, str]]:
|
|
197
|
+
"""(folder holding skills or commands, plugin name or '') for everything Claude Code has installed."""
|
|
198
|
+
out: list[tuple[pathlib.Path, str]] = []
|
|
199
|
+
if not CLAUDE_DIR.is_dir():
|
|
200
|
+
return out
|
|
201
|
+
skills = CLAUDE_DIR / "skills"
|
|
202
|
+
if skills.is_dir():
|
|
203
|
+
out.append((skills, ""))
|
|
204
|
+
synced = skills / "synced"
|
|
205
|
+
if synced.is_dir():
|
|
206
|
+
out += [(d, "") for d in synced.iterdir() if d.is_dir()]
|
|
207
|
+
plugins: list[pathlib.Path] = []
|
|
208
|
+
try:
|
|
209
|
+
idx = json.loads((CLAUDE_DIR / "plugins" / "installed_plugins.json").read_text(encoding="utf-8"))
|
|
210
|
+
for entries in (idx.get("plugins") or {}).values():
|
|
211
|
+
for e in entries if isinstance(entries, list) else [entries]:
|
|
212
|
+
p = pathlib.Path(e.get("installPath", ""))
|
|
213
|
+
if p.is_dir():
|
|
214
|
+
plugins.append(p)
|
|
215
|
+
except (OSError, ValueError, AttributeError):
|
|
216
|
+
pass
|
|
217
|
+
synced = CLAUDE_DIR / "plugins" / "synced"
|
|
218
|
+
if synced.is_dir():
|
|
219
|
+
for bucket in synced.iterdir():
|
|
220
|
+
if bucket.is_dir() and not bucket.name.startswith("."):
|
|
221
|
+
plugins += [p for p in bucket.iterdir() if p.is_dir() and not p.name.startswith(".")]
|
|
222
|
+
for p in plugins:
|
|
223
|
+
name = p.name if not re.match(r"^\d+\.\d+", p.name) else p.parent.name
|
|
224
|
+
try:
|
|
225
|
+
name = json.loads((p / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))["name"]
|
|
226
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
227
|
+
pass
|
|
228
|
+
name = str(name).split("~")[0]
|
|
229
|
+
for sub in ("skills", "commands"):
|
|
230
|
+
if (p / sub).is_dir():
|
|
231
|
+
out.append((p / sub, name))
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _entries(d: pathlib.Path):
|
|
236
|
+
"""Skill files directly in d: <name>/SKILL.md or <name>.md."""
|
|
237
|
+
try:
|
|
238
|
+
items = sorted(d.iterdir())
|
|
239
|
+
except OSError:
|
|
240
|
+
return
|
|
241
|
+
for entry in items:
|
|
242
|
+
if entry.is_dir() and (entry / "SKILL.md").is_file():
|
|
243
|
+
yield entry / "SKILL.md", entry.name
|
|
244
|
+
elif entry.is_file() and entry.suffix.lower() == ".md" and entry.stem.upper() != "README":
|
|
245
|
+
yield entry, entry.stem
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _load(path: pathlib.Path, default: str) -> tuple[str, str] | None:
|
|
249
|
+
try:
|
|
250
|
+
meta, body = _split(path.read_text(encoding="utf-8", errors="replace"))
|
|
251
|
+
except OSError:
|
|
252
|
+
return None
|
|
253
|
+
name = (meta.get("name") or default).strip().lower().replace(" ", "-")
|
|
254
|
+
if not NAME_RE.match(name):
|
|
255
|
+
return None
|
|
256
|
+
desc = meta.get("description") or next(
|
|
257
|
+
(ln.strip("# ").strip() for ln in body.splitlines() if ln.strip()), "")
|
|
258
|
+
return name, " ".join(desc.split())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def discover() -> dict[str, Skill]:
|
|
262
|
+
found: dict[str, Skill] = {}
|
|
263
|
+
# Claude Code's skills first, so Forge's own (and yours) win on a name clash.
|
|
264
|
+
for d, plugin in _sources():
|
|
265
|
+
root = _plugin_root(d)
|
|
266
|
+
needs = _needs(root, plugin)
|
|
267
|
+
for path, default in _entries(d):
|
|
268
|
+
got = _load(path, default)
|
|
269
|
+
if not got:
|
|
270
|
+
continue
|
|
271
|
+
name, desc = got
|
|
272
|
+
sk = Skill(name, desc, path, "claude", plugin or "skills", needs)
|
|
273
|
+
if plugin:
|
|
274
|
+
found[f"{plugin}:{name}"] = Skill(f"{plugin}:{name}", desc, path, "claude", plugin, needs)
|
|
275
|
+
# The short name goes to the first skill that has it, preferring ones that need no connector.
|
|
276
|
+
if name not in found or (found[name].needs and not needs):
|
|
277
|
+
found[name] = sk
|
|
278
|
+
for source, d in skill_dirs():
|
|
279
|
+
if not d.is_dir():
|
|
280
|
+
continue
|
|
281
|
+
for path, default in _entries(d):
|
|
282
|
+
got = _load(path, default)
|
|
283
|
+
if got:
|
|
284
|
+
found[got[0]] = Skill(got[0], got[1], path, source)
|
|
285
|
+
return found
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def skill_dirs() -> list[tuple[str, pathlib.Path]]:
|
|
289
|
+
installed = pathlib.Path(sys.prefix) / "skills"
|
|
290
|
+
builtin = installed if installed.is_dir() else APP_DIR / "skills"
|
|
291
|
+
return [("bundled", builtin / "library"),
|
|
292
|
+
("built-in", builtin),
|
|
293
|
+
("user", HOME_DIR / "skills"),
|
|
294
|
+
("project", pathlib.Path.cwd() / ".forge" / "skills")]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def listed(skills: dict[str, Skill]) -> list[Skill]:
|
|
298
|
+
"""Skills named in the system prompt: Forge's own plus yours. The rest are found with find_skill."""
|
|
299
|
+
return [s for s in skills.values() if s.source not in ("claude", "bundled")]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def find(skills: dict[str, Skill], query: str, limit: int = 8) -> list[Skill]:
|
|
303
|
+
words = [w for w in re.findall(r"[a-z0-9]+", query.lower()) if len(w) > 1]
|
|
304
|
+
if not words:
|
|
305
|
+
return []
|
|
306
|
+
seen_paths, scored = set(), []
|
|
307
|
+
for s in skills.values():
|
|
308
|
+
if s.path in seen_paths:
|
|
309
|
+
continue
|
|
310
|
+
name, desc = s.name.lower(), s.description.lower()
|
|
311
|
+
score = sum(3 * (w in name) + desc.count(w) for w in words)
|
|
312
|
+
if score:
|
|
313
|
+
scored.append((score - (2 if s.needs else 0), s))
|
|
314
|
+
scored.sort(key=lambda x: -x[0])
|
|
315
|
+
out = []
|
|
316
|
+
for _, s in scored:
|
|
317
|
+
if s.path not in seen_paths:
|
|
318
|
+
seen_paths.add(s.path)
|
|
319
|
+
out.append(s)
|
|
320
|
+
if len(out) >= limit:
|
|
321
|
+
break
|
|
322
|
+
return out
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# --- automatic skill detection --------------------------------------------------------
|
|
326
|
+
# A local BM25 index over skill names + descriptions: picks the skill a request needs in about a
|
|
327
|
+
# millisecond, with no model call, so the right instructions ride along with the very first request.
|
|
328
|
+
|
|
329
|
+
_STOP = set("""a an the and or but if then so to of in on at by for with from into onto about as is are was
|
|
330
|
+
were be been being it its this that these those i me my we our you your he she they them their what which
|
|
331
|
+
who whom how why when where can could should would will shall may might must do does did done make made
|
|
332
|
+
get got have has had please just also some any all more most very really want need like use using used
|
|
333
|
+
help me let lets let's one two new now up out over not no yes thing things stuff something way forge forgecli""".split())
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _stem(w: str) -> str:
|
|
337
|
+
for suf in ("ing", "ies", "es", "ed", "s"):
|
|
338
|
+
if len(w) > len(suf) + 2 and w.endswith(suf):
|
|
339
|
+
return w[: -len(suf)] + ("y" if suf == "ies" else "")
|
|
340
|
+
return w
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _terms(text: str) -> list[str]:
|
|
344
|
+
return [_stem(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if len(w) > 1 and w not in _STOP]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class Matcher:
|
|
348
|
+
"""Built once per skill set. match(text) → (skill, confidence) or (None, 0)."""
|
|
349
|
+
K1, B = 1.2, 0.6
|
|
350
|
+
MIN_SCORE = 6.0 # below this nothing is confidently relevant
|
|
351
|
+
MIN_LEAD = 1.25 # the winner must beat the runner-up by this factor
|
|
352
|
+
|
|
353
|
+
def __init__(self, skills: dict[str, "Skill"]):
|
|
354
|
+
import math
|
|
355
|
+
uniq, seen = [], set()
|
|
356
|
+
for s in skills.values():
|
|
357
|
+
if s.path not in seen:
|
|
358
|
+
seen.add(s.path)
|
|
359
|
+
uniq.append(s)
|
|
360
|
+
self.docs = []
|
|
361
|
+
df: dict[str, int] = {}
|
|
362
|
+
for s in uniq:
|
|
363
|
+
name_terms = _terms(s.name.replace("-", " ").replace(":", " "))
|
|
364
|
+
terms = name_terms * 3 + _terms(s.description) # the name counts triple
|
|
365
|
+
tf: dict[str, int] = {}
|
|
366
|
+
for t in terms:
|
|
367
|
+
tf[t] = tf.get(t, 0) + 1
|
|
368
|
+
for t in tf:
|
|
369
|
+
df[t] = df.get(t, 0) + 1
|
|
370
|
+
self.docs.append((s, tf, len(terms)))
|
|
371
|
+
n = max(1, len(self.docs))
|
|
372
|
+
self.avg = sum(d[2] for d in self.docs) / n if self.docs else 1
|
|
373
|
+
self.idf = {t: math.log(1 + (n - c + 0.5) / (c + 0.5)) for t, c in df.items()}
|
|
374
|
+
# IDF (and so scores) shrink with fewer skills; scale the bar to match (tuned on ~200 skills)
|
|
375
|
+
self.min_score = self.MIN_SCORE * min(1.0, math.log(1 + n) / math.log(201))
|
|
376
|
+
|
|
377
|
+
def scores(self, text: str) -> list[tuple[float, "Skill"]]:
|
|
378
|
+
q = set(_terms(text))
|
|
379
|
+
out = []
|
|
380
|
+
for s, tf, length in self.docs:
|
|
381
|
+
sc = 0.0
|
|
382
|
+
for t in q:
|
|
383
|
+
f = tf.get(t)
|
|
384
|
+
if f:
|
|
385
|
+
sc += self.idf[t] * f * (self.K1 + 1) / (f + self.K1 * (1 - self.B + self.B * length / self.avg))
|
|
386
|
+
if sc:
|
|
387
|
+
if s.needs: # needs a connector Forge doesn't have
|
|
388
|
+
sc *= 0.6
|
|
389
|
+
if s.source != "claude":
|
|
390
|
+
sc *= 1.15 # Forge's own skills fit Forge's tools best
|
|
391
|
+
out.append((sc, s))
|
|
392
|
+
out.sort(key=lambda x: -x[0])
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
def match(self, text: str) -> tuple["Skill | None", float]:
|
|
396
|
+
if len(text) < 12 or text.lstrip().startswith("/"):
|
|
397
|
+
return None, 0.0
|
|
398
|
+
ranked = self.scores(text)
|
|
399
|
+
if not ranked:
|
|
400
|
+
return None, 0.0
|
|
401
|
+
best, s = ranked[0]
|
|
402
|
+
second = ranked[1][0] if len(ranked) > 1 else 0.0
|
|
403
|
+
if best >= self.min_score and best >= second * self.MIN_LEAD:
|
|
404
|
+
return s, best
|
|
405
|
+
return None, best
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def create(name: str, project: bool = False) -> pathlib.Path:
|
|
409
|
+
name = name.strip().lower()
|
|
410
|
+
if not re.match(r"^[a-z0-9][a-z0-9_-]{0,40}$", name):
|
|
411
|
+
raise ValueError("use lowercase letters, digits, - or _ (max 41 chars)")
|
|
412
|
+
base = (pathlib.Path.cwd() / ".forge" / "skills") if project else (HOME_DIR / "skills")
|
|
413
|
+
path = base / name / "SKILL.md"
|
|
414
|
+
if path.exists():
|
|
415
|
+
raise ValueError(f"{path} already exists")
|
|
416
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
path.write_text(f"""---
|
|
418
|
+
name: {name}
|
|
419
|
+
description: One line saying what this skill does and when to use it
|
|
420
|
+
---
|
|
421
|
+
# {name}
|
|
422
|
+
|
|
423
|
+
Write the instructions the agent should follow when this skill is used.
|
|
424
|
+
For example: which files to look at, which commands to run, what the
|
|
425
|
+
result should look like, and what to avoid.
|
|
426
|
+
""", encoding="utf-8")
|
|
427
|
+
return path
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def use_skill_schema(skills: dict[str, Skill]) -> dict:
|
|
431
|
+
return {"type": "function", "function": {
|
|
432
|
+
"name": "use_skill",
|
|
433
|
+
"description": "Load the full instructions of a skill (by exact name, from the list or from "
|
|
434
|
+
"find_skill). Call it before starting a task that matches, then follow them.",
|
|
435
|
+
"parameters": {"type": "object", "properties": {
|
|
436
|
+
"name": {"type": "string"}}, "required": ["name"]}}}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
FIND_SCHEMA = {"type": "function", "function": {
|
|
440
|
+
"name": "find_skill",
|
|
441
|
+
"description": "Search the library of expert skills (engineering, web development, design, testing, "
|
|
442
|
+
"debugging, security, data, documents like pdf/docx/xlsx/pptx, research, marketing, "
|
|
443
|
+
"sales, video, audio…) by keywords. Use it at the start of any non-trivial task.",
|
|
444
|
+
"parameters": {"type": "object", "properties": {
|
|
445
|
+
"query": {"type": "string", "description": "a few keywords, e.g. 'react frontend ui'"}},
|
|
446
|
+
"required": ["query"]}}}
|