seam-code 0.3.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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Agent-config installer for `seam install` / `seam uninstall`.
|
|
2
|
+
|
|
3
|
+
Writes an MCP stdio server entry ("seam") into a coding agent's config so the
|
|
4
|
+
agent auto-discovers the local Seam server. CLI-only — the MCP server itself is
|
|
5
|
+
read-only, so there is no `seam_install` tool.
|
|
6
|
+
|
|
7
|
+
Layering (leaf → composite):
|
|
8
|
+
jsonfile.py / tomlfile.py — pure file format ops (stdlib json / tomlkit), no Seam deps
|
|
9
|
+
core.py — AgentTarget ABC, InstallResult, shared JSON install/uninstall
|
|
10
|
+
claude.py / cursor.py / codex.py — one AgentTarget each (own path + format)
|
|
11
|
+
this module — TARGETS registry + helpers (get_target, resolve_seam_command)
|
|
12
|
+
|
|
13
|
+
Adding an agent later = one target file + one registry entry.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from seam.installer.claude import ClaudeTarget
|
|
21
|
+
from seam.installer.codex import CodexTarget
|
|
22
|
+
from seam.installer.core import AgentTarget, InstallResult
|
|
23
|
+
from seam.installer.cursor import CursorTarget
|
|
24
|
+
|
|
25
|
+
# Registry — keys are the `--target` values the CLI accepts (besides "all").
|
|
26
|
+
TARGETS: dict[str, AgentTarget] = {
|
|
27
|
+
"claude": ClaudeTarget(),
|
|
28
|
+
"cursor": CursorTarget(),
|
|
29
|
+
"codex": CodexTarget(),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_target(name: str) -> AgentTarget | None:
|
|
34
|
+
"""Return the target for `name`, or None if unknown (CLI maps None → INVALID_INPUT)."""
|
|
35
|
+
return TARGETS.get(name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_seam_command() -> tuple[str, bool]:
|
|
39
|
+
"""Resolve the absolute executable to put in the agent config.
|
|
40
|
+
|
|
41
|
+
Returns (command, found). The agent spawns the server with an unknown working
|
|
42
|
+
directory and PATH, so we want an ABSOLUTE, self-contained command. Resolution
|
|
43
|
+
order, most-to-least reliable:
|
|
44
|
+
1. sys.argv[0] — when invoked as the `seam` console script, this IS that
|
|
45
|
+
script's path (no PATH/cwd assumptions). Only trusted when it exists and is
|
|
46
|
+
named seam* (under pytest/`python -m` it is the test runner — skip it then).
|
|
47
|
+
2. shutil.which("seam") — the installed console script on PATH.
|
|
48
|
+
3. bare "seam" with found=False — correct once published / on PATH; CLI warns.
|
|
49
|
+
"""
|
|
50
|
+
argv0 = sys.argv[0] if sys.argv else ""
|
|
51
|
+
if argv0:
|
|
52
|
+
candidate = Path(argv0)
|
|
53
|
+
if candidate.name.startswith("seam") and candidate.exists():
|
|
54
|
+
return str(candidate.resolve()), True
|
|
55
|
+
found = shutil.which("seam")
|
|
56
|
+
if found:
|
|
57
|
+
return found, True
|
|
58
|
+
return "seam", False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"TARGETS",
|
|
63
|
+
"AgentTarget",
|
|
64
|
+
"InstallResult",
|
|
65
|
+
"get_target",
|
|
66
|
+
"resolve_seam_command",
|
|
67
|
+
]
|
seam/installer/claude.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Claude Code install target.
|
|
2
|
+
|
|
3
|
+
Project scope → <root>/.mcp.json, key mcpServers.seam (auto-discovered, team-shareable).
|
|
4
|
+
User scope → ~/.claude.json, key projects.<abs-root>.mcpServers.seam (per-project local,
|
|
5
|
+
matching `claude mcp add --scope local`).
|
|
6
|
+
Entry shape requires "type": "stdio" (Claude Code requires it; not implied by `command`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from seam.installer import guide
|
|
13
|
+
from seam.installer.core import AgentTarget, InstallResult, install_entry, uninstall_entry
|
|
14
|
+
from seam.installer.markdownfile import (
|
|
15
|
+
remove_block,
|
|
16
|
+
remove_file,
|
|
17
|
+
upsert_block,
|
|
18
|
+
wrap_block,
|
|
19
|
+
write_file,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_SERVER_NAME = "seam"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClaudeTarget(AgentTarget):
|
|
26
|
+
name = "claude"
|
|
27
|
+
|
|
28
|
+
def supported_locations(self) -> list[str]:
|
|
29
|
+
return ["project", "user"]
|
|
30
|
+
|
|
31
|
+
def config_path(self, root: Path, location: str) -> Path:
|
|
32
|
+
if location == "project":
|
|
33
|
+
return root / ".mcp.json"
|
|
34
|
+
return Path.home() / ".claude.json"
|
|
35
|
+
|
|
36
|
+
def _key_path(self, root: Path, location: str) -> list[str]:
|
|
37
|
+
if location == "project":
|
|
38
|
+
return ["mcpServers", _SERVER_NAME]
|
|
39
|
+
# User scope nests servers per absolute project path.
|
|
40
|
+
return ["projects", str(root), "mcpServers", _SERVER_NAME]
|
|
41
|
+
|
|
42
|
+
def _entry(self, command: str, args: list[str]) -> dict[str, object]:
|
|
43
|
+
return {"type": "stdio", "command": command, "args": args}
|
|
44
|
+
|
|
45
|
+
def install(self, root: Path, location: str, command: str, args: list[str]) -> InstallResult:
|
|
46
|
+
return install_entry(
|
|
47
|
+
self.config_path(root, location),
|
|
48
|
+
self._key_path(root, location),
|
|
49
|
+
self._entry(command, args),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def uninstall(self, root: Path, location: str) -> InstallResult:
|
|
53
|
+
return uninstall_entry(self.config_path(root, location), self._key_path(root, location))
|
|
54
|
+
|
|
55
|
+
def render_entry(self, command: str, args: list[str]) -> str:
|
|
56
|
+
return json.dumps({"mcpServers": {_SERVER_NAME: self._entry(command, args)}}, indent=2)
|
|
57
|
+
|
|
58
|
+
# ── CLI guidance: a project skill + a thin CLAUDE.md discovery pointer ─────
|
|
59
|
+
|
|
60
|
+
def _skill_path(self, root: Path) -> Path:
|
|
61
|
+
return root / ".claude" / "skills" / _SERVER_NAME / "SKILL.md"
|
|
62
|
+
|
|
63
|
+
def _claude_md(self, root: Path) -> Path:
|
|
64
|
+
return root / "CLAUDE.md"
|
|
65
|
+
|
|
66
|
+
def install_guidance(self, root: Path) -> list[InstallResult]:
|
|
67
|
+
skill = self._skill_path(root)
|
|
68
|
+
claude_md = self._claude_md(root)
|
|
69
|
+
return [
|
|
70
|
+
InstallResult(write_file(skill, guide.render_skill()), str(skill)),
|
|
71
|
+
InstallResult(
|
|
72
|
+
upsert_block(claude_md, guide.render_claude_hook(), marker=guide.BLOCK_MARKER),
|
|
73
|
+
str(claude_md),
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def uninstall_guidance(self, root: Path) -> list[InstallResult]:
|
|
78
|
+
skill = self._skill_path(root)
|
|
79
|
+
claude_md = self._claude_md(root)
|
|
80
|
+
action = remove_file(skill)
|
|
81
|
+
# Tidy the now-empty skill directory we created; ignore if non-empty/missing.
|
|
82
|
+
skill_dir = skill.parent
|
|
83
|
+
if skill_dir.exists() and not any(skill_dir.iterdir()):
|
|
84
|
+
skill_dir.rmdir()
|
|
85
|
+
return [
|
|
86
|
+
InstallResult(action, str(skill)),
|
|
87
|
+
InstallResult(remove_block(claude_md, marker=guide.BLOCK_MARKER), str(claude_md)),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def guidance_previews(self, root: Path) -> list[tuple[str, str]]:
|
|
91
|
+
return [
|
|
92
|
+
(str(self._skill_path(root)), guide.render_skill()),
|
|
93
|
+
(
|
|
94
|
+
str(self._claude_md(root)),
|
|
95
|
+
wrap_block(guide.render_claude_hook(), guide.BLOCK_MARKER),
|
|
96
|
+
),
|
|
97
|
+
]
|
seam/installer/codex.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Codex install target.
|
|
2
|
+
|
|
3
|
+
User scope → ~/.codex/config.toml, table [mcp_servers.seam] with command + args.
|
|
4
|
+
Codex's project-scoped .codex/config.toml only applies in "trusted projects", so
|
|
5
|
+
the MVP supports user scope only. Uses tomlkit (tomlfile) to preserve the user's
|
|
6
|
+
existing comments/formatting on round-trip.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import tomlkit
|
|
12
|
+
|
|
13
|
+
from seam.installer import guide
|
|
14
|
+
from seam.installer.core import AgentTarget, InstallResult
|
|
15
|
+
from seam.installer.markdownfile import remove_block, upsert_block, wrap_block
|
|
16
|
+
from seam.installer.tomlfile import (
|
|
17
|
+
atomic_write_toml,
|
|
18
|
+
delete_server_table,
|
|
19
|
+
get_server_table,
|
|
20
|
+
load_toml,
|
|
21
|
+
set_server_table,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SERVER_NAME = "seam"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CodexTarget(AgentTarget):
|
|
28
|
+
name = "codex"
|
|
29
|
+
|
|
30
|
+
def supported_locations(self) -> list[str]:
|
|
31
|
+
return ["user"]
|
|
32
|
+
|
|
33
|
+
def config_path(self, root: Path, location: str) -> Path:
|
|
34
|
+
return Path.home() / ".codex" / "config.toml"
|
|
35
|
+
|
|
36
|
+
def _entry(self, command: str, args: list[str]) -> dict[str, object]:
|
|
37
|
+
return {"command": command, "args": args}
|
|
38
|
+
|
|
39
|
+
def install(self, root: Path, location: str, command: str, args: list[str]) -> InstallResult:
|
|
40
|
+
path = self.config_path(root, location)
|
|
41
|
+
existed = path.exists()
|
|
42
|
+
backed_up = _backup_if_corrupt(path)
|
|
43
|
+
entry = self._entry(command, args)
|
|
44
|
+
|
|
45
|
+
doc = load_toml(path) or tomlkit.document()
|
|
46
|
+
if get_server_table(doc, _SERVER_NAME) == entry:
|
|
47
|
+
return InstallResult("unchanged", str(path), entry, backed_up)
|
|
48
|
+
|
|
49
|
+
set_server_table(doc, _SERVER_NAME, entry)
|
|
50
|
+
atomic_write_toml(path, doc)
|
|
51
|
+
return InstallResult("created" if not existed else "updated", str(path), entry, backed_up)
|
|
52
|
+
|
|
53
|
+
def uninstall(self, root: Path, location: str) -> InstallResult:
|
|
54
|
+
path = self.config_path(root, location)
|
|
55
|
+
doc = load_toml(path)
|
|
56
|
+
if doc is None:
|
|
57
|
+
return InstallResult("not_present", str(path))
|
|
58
|
+
if delete_server_table(doc, _SERVER_NAME):
|
|
59
|
+
atomic_write_toml(path, doc)
|
|
60
|
+
return InstallResult("removed", str(path))
|
|
61
|
+
return InstallResult("not_present", str(path))
|
|
62
|
+
|
|
63
|
+
def render_entry(self, command: str, args: list[str]) -> str:
|
|
64
|
+
doc = tomlkit.document()
|
|
65
|
+
set_server_table(doc, _SERVER_NAME, self._entry(command, args))
|
|
66
|
+
return tomlkit.dumps(doc).strip()
|
|
67
|
+
|
|
68
|
+
# ── CLI guidance: an inline AGENTS.md block (Codex has no skill/rule) ──────
|
|
69
|
+
# Project-scoped (repo root), unlike Codex's user-only MCP config.
|
|
70
|
+
|
|
71
|
+
def _agents_md(self, root: Path) -> Path:
|
|
72
|
+
return root / "AGENTS.md"
|
|
73
|
+
|
|
74
|
+
def install_guidance(self, root: Path) -> list[InstallResult]:
|
|
75
|
+
agents = self._agents_md(root)
|
|
76
|
+
action = upsert_block(agents, guide.render_codex_block(), marker=guide.BLOCK_MARKER)
|
|
77
|
+
return [InstallResult(action, str(agents))]
|
|
78
|
+
|
|
79
|
+
def uninstall_guidance(self, root: Path) -> list[InstallResult]:
|
|
80
|
+
agents = self._agents_md(root)
|
|
81
|
+
return [InstallResult(remove_block(agents, marker=guide.BLOCK_MARKER), str(agents))]
|
|
82
|
+
|
|
83
|
+
def guidance_previews(self, root: Path) -> list[tuple[str, str]]:
|
|
84
|
+
agents = self._agents_md(root)
|
|
85
|
+
return [(str(agents), wrap_block(guide.render_codex_block(), guide.BLOCK_MARKER))]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _backup_if_corrupt(path: Path) -> bool:
|
|
89
|
+
"""Back up a present-but-unparseable config.toml before we overwrite it."""
|
|
90
|
+
if path.exists() and load_toml(path) is None:
|
|
91
|
+
backup = path.with_suffix(path.suffix + ".backup")
|
|
92
|
+
backup.write_bytes(path.read_bytes())
|
|
93
|
+
return True
|
|
94
|
+
return False
|
seam/installer/core.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Installer core — the AgentTarget contract, InstallResult, and the shared
|
|
2
|
+
idempotent JSON merge used by the JSON-format targets (Claude, Cursor).
|
|
3
|
+
|
|
4
|
+
The TOML target (Codex) shares InstallResult but implements its own merge in
|
|
5
|
+
codex.py via tomlfile — TOML table editing differs enough from nested-dict JSON
|
|
6
|
+
that forcing it through this path would be more convoluted than separate code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from seam.installer.jsonfile import (
|
|
15
|
+
atomic_write_json,
|
|
16
|
+
delete_in,
|
|
17
|
+
get_in,
|
|
18
|
+
load_json,
|
|
19
|
+
set_in,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class InstallResult:
|
|
25
|
+
"""Outcome of a single install/uninstall against one target+location.
|
|
26
|
+
|
|
27
|
+
action:
|
|
28
|
+
created — config file did not exist; we created it with the entry
|
|
29
|
+
updated — config file existed; we added or changed our entry
|
|
30
|
+
unchanged — our entry already deep-equals the desired entry (no write)
|
|
31
|
+
removed — uninstall deleted an existing entry
|
|
32
|
+
not_present — uninstall found nothing to remove
|
|
33
|
+
backed_up: a present-but-corrupt config was copied to <path>.backup before write.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
action: str
|
|
37
|
+
path: str
|
|
38
|
+
entry: dict[str, Any] | None = None
|
|
39
|
+
backed_up: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgentTarget(ABC):
|
|
43
|
+
"""A coding agent Seam can install itself into. One concrete class per agent."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def supported_locations(self) -> list[str]:
|
|
49
|
+
"""Which `--location` values this target accepts (e.g. ["project", "user"])."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def config_path(self, root: Path, location: str) -> Path:
|
|
53
|
+
"""Absolute path of the config file this target writes for `location`."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def install(self, root: Path, location: str, command: str, args: list[str]) -> InstallResult:
|
|
57
|
+
"""Write/merge the seam MCP entry. Idempotent."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def uninstall(self, root: Path, location: str) -> InstallResult:
|
|
61
|
+
"""Remove the seam MCP entry if present."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def render_entry(self, command: str, args: list[str]) -> str:
|
|
65
|
+
"""Human-readable preview of the entry this target would write (for --print-config)."""
|
|
66
|
+
|
|
67
|
+
# ── CLI-first guidance (the install default) ──────────────────────────────
|
|
68
|
+
# Guidance is the token-lean playbook the agent reads to use the `seam` CLI.
|
|
69
|
+
# It is ALWAYS project-scoped (it lives in the repo: a skill, a rule, or an
|
|
70
|
+
# AGENTS.md block), independent of the MCP `location` — so it ignores location
|
|
71
|
+
# and a target whose MCP is user-only (Codex) can still receive repo guidance.
|
|
72
|
+
# A target may write more than one artifact (Claude writes a skill + a hook),
|
|
73
|
+
# hence list[InstallResult].
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def install_guidance(self, root: Path) -> list[InstallResult]:
|
|
77
|
+
"""Write this target's CLI guidance artifact(s) into the repo. Idempotent."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def uninstall_guidance(self, root: Path) -> list[InstallResult]:
|
|
81
|
+
"""Remove this target's CLI guidance artifact(s) if present."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def guidance_previews(self, root: Path) -> list[tuple[str, str]]:
|
|
85
|
+
"""(path, content) pairs this target would write — for --print-config."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _backup_if_corrupt(path: Path) -> bool:
|
|
89
|
+
"""If `path` exists but does not parse as JSON, copy it to <path>.backup.
|
|
90
|
+
|
|
91
|
+
Protects a user's hand-edited config from being silently destroyed when we
|
|
92
|
+
have to overwrite an unparseable file. Returns True iff a backup was made.
|
|
93
|
+
"""
|
|
94
|
+
if path.exists() and load_json(path) is None:
|
|
95
|
+
backup = path.with_suffix(path.suffix + ".backup")
|
|
96
|
+
backup.write_bytes(path.read_bytes())
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def install_entry(path: Path, key_path: list[str], entry: dict[str, Any]) -> InstallResult:
|
|
102
|
+
"""Idempotently set `entry` at `key_path` in the JSON file at `path`.
|
|
103
|
+
|
|
104
|
+
Preserves every other key in the file. Deep-equal entry → unchanged (no write).
|
|
105
|
+
"""
|
|
106
|
+
existed = path.exists()
|
|
107
|
+
backed_up = _backup_if_corrupt(path)
|
|
108
|
+
data = load_json(path) or {}
|
|
109
|
+
|
|
110
|
+
if get_in(data, key_path) == entry:
|
|
111
|
+
return InstallResult("unchanged", str(path), entry, backed_up)
|
|
112
|
+
|
|
113
|
+
set_in(data, key_path, entry)
|
|
114
|
+
atomic_write_json(path, data)
|
|
115
|
+
return InstallResult("created" if not existed else "updated", str(path), entry, backed_up)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def uninstall_entry(path: Path, key_path: list[str]) -> InstallResult:
|
|
119
|
+
"""Remove the leaf at `key_path` from the JSON file at `path`, if present."""
|
|
120
|
+
data = load_json(path)
|
|
121
|
+
if data is None:
|
|
122
|
+
# Absent or unparseable → nothing we recognize to remove.
|
|
123
|
+
return InstallResult("not_present", str(path))
|
|
124
|
+
if delete_in(data, key_path):
|
|
125
|
+
atomic_write_json(path, data)
|
|
126
|
+
return InstallResult("removed", str(path))
|
|
127
|
+
return InstallResult("not_present", str(path))
|
seam/installer/cursor.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Cursor install target.
|
|
2
|
+
|
|
3
|
+
Project scope → <root>/.cursor/mcp.json; user/global scope → ~/.cursor/mcp.json.
|
|
4
|
+
Both use {"mcpServers": {"seam": {"command", "args"}}}. Unlike Claude, Cursor does
|
|
5
|
+
NOT use a "type" field — a command-based entry is stdio by definition.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from seam.installer import guide
|
|
12
|
+
from seam.installer.core import AgentTarget, InstallResult, install_entry, uninstall_entry
|
|
13
|
+
from seam.installer.markdownfile import remove_file, write_file
|
|
14
|
+
|
|
15
|
+
_SERVER_NAME = "seam"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CursorTarget(AgentTarget):
|
|
19
|
+
name = "cursor"
|
|
20
|
+
|
|
21
|
+
def supported_locations(self) -> list[str]:
|
|
22
|
+
return ["project", "user"]
|
|
23
|
+
|
|
24
|
+
def config_path(self, root: Path, location: str) -> Path:
|
|
25
|
+
if location == "project":
|
|
26
|
+
return root / ".cursor" / "mcp.json"
|
|
27
|
+
return Path.home() / ".cursor" / "mcp.json"
|
|
28
|
+
|
|
29
|
+
def _key_path(self) -> list[str]:
|
|
30
|
+
return ["mcpServers", _SERVER_NAME]
|
|
31
|
+
|
|
32
|
+
def _entry(self, command: str, args: list[str]) -> dict[str, object]:
|
|
33
|
+
return {"command": command, "args": args}
|
|
34
|
+
|
|
35
|
+
def install(self, root: Path, location: str, command: str, args: list[str]) -> InstallResult:
|
|
36
|
+
return install_entry(
|
|
37
|
+
self.config_path(root, location), self._key_path(), self._entry(command, args)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def uninstall(self, root: Path, location: str) -> InstallResult:
|
|
41
|
+
return uninstall_entry(self.config_path(root, location), self._key_path())
|
|
42
|
+
|
|
43
|
+
def render_entry(self, command: str, args: list[str]) -> str:
|
|
44
|
+
return json.dumps({"mcpServers": {_SERVER_NAME: self._entry(command, args)}}, indent=2)
|
|
45
|
+
|
|
46
|
+
# ── CLI guidance: an "Agent Requested" project rule (progressive) ─────────
|
|
47
|
+
|
|
48
|
+
def _rule_path(self, root: Path) -> Path:
|
|
49
|
+
# Must be `.mdc` (a plain `.md` in .cursor/rules/ is ignored by Cursor).
|
|
50
|
+
return root / ".cursor" / "rules" / f"{_SERVER_NAME}.mdc"
|
|
51
|
+
|
|
52
|
+
def install_guidance(self, root: Path) -> list[InstallResult]:
|
|
53
|
+
rule = self._rule_path(root)
|
|
54
|
+
return [InstallResult(write_file(rule, guide.render_cursor_rule()), str(rule))]
|
|
55
|
+
|
|
56
|
+
def uninstall_guidance(self, root: Path) -> list[InstallResult]:
|
|
57
|
+
rule = self._rule_path(root)
|
|
58
|
+
return [InstallResult(remove_file(rule), str(rule))]
|
|
59
|
+
|
|
60
|
+
def guidance_previews(self, root: Path) -> list[tuple[str, str]]:
|
|
61
|
+
return [(str(self._rule_path(root)), guide.render_cursor_rule())]
|
seam/installer/guide.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""LEAF: the single source of Seam's agent-facing CLI guidance + per-format renderers.
|
|
2
|
+
|
|
3
|
+
One guide body, rendered into each agent's cheapest native mechanism so the
|
|
4
|
+
formats can never drift:
|
|
5
|
+
|
|
6
|
+
* Claude Code → a project skill (`SKILL.md`): the description sits in the
|
|
7
|
+
always-loaded skill listing; the body loads only when the skill is invoked.
|
|
8
|
+
* Cursor → an "Agent Requested" rule (`.mdc`): surfaced by its
|
|
9
|
+
`description`, pulled in only when relevant (`alwaysApply: false`).
|
|
10
|
+
* Codex → an inline `AGENTS.md` block (Codex has no progressive
|
|
11
|
+
mechanism, so the full guide is always-loaded there).
|
|
12
|
+
|
|
13
|
+
The thin CLAUDE.md hook is a separate, tiny always-loaded pointer that boosts
|
|
14
|
+
skill-discovery reliability (~70-85% → ~95%) for ~20 tokens.
|
|
15
|
+
|
|
16
|
+
Pure: string constants + string builders. No IO, no Seam deps, never raises.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Marker name for the shared-file blocks (AGENTS.md / CLAUDE.md). The
|
|
20
|
+
# markdownfile leaf turns this into `<!-- seam:start -->` … `<!-- seam:end -->`.
|
|
21
|
+
BLOCK_MARKER = "seam"
|
|
22
|
+
|
|
23
|
+
# Retrieval-tuned discovery hook. No apostrophes and no "colon-space" so it is a
|
|
24
|
+
# safe single-quoted YAML scalar in both the skill and the Cursor rule frontmatter.
|
|
25
|
+
_DESCRIPTION = (
|
|
26
|
+
"Query the Seam code-intelligence index for this repo via the `seam` CLI "
|
|
27
|
+
"instead of grep — find callers, blast radius, symbol definitions, and repo "
|
|
28
|
+
"structure. Use when exploring or editing code in this repository."
|
|
29
|
+
)
|
|
30
|
+
_WHEN_TO_USE = (
|
|
31
|
+
'Use for "who calls X", "what breaks if I change X", "where is X defined", '
|
|
32
|
+
'or "map this repo".'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# The escalation ladder is the whole point: start cheap (--quiet names), escalate
|
|
36
|
+
# to --json --lean only for risk tiers, full --json only for provenance.
|
|
37
|
+
_GUIDE_BODY = """\
|
|
38
|
+
# Using Seam (code intelligence)
|
|
39
|
+
|
|
40
|
+
Seam has indexed this repo into `.seam/seam.db` — a symbol + call graph.
|
|
41
|
+
Query it instead of grepping: faster, and far cheaper in tokens.
|
|
42
|
+
|
|
43
|
+
## Keep the index fresh first
|
|
44
|
+
- No `.seam/seam.db`? → `seam init`
|
|
45
|
+
- `seam status` says stale? → `seam sync`
|
|
46
|
+
|
|
47
|
+
## Escalation ladder — spend the fewest tokens, escalate only when needed
|
|
48
|
+
1. Discover / orient `seam search <text> --quiet` · `seam query <concept> --quiet`
|
|
49
|
+
`seam context <symbol> --quiet` (names + locations only)
|
|
50
|
+
2. Change risk `seam impact <symbol> --json --lean` (risk tiers, no enrichment)
|
|
51
|
+
3. Full provenance `seam impact <symbol> --json` (confidence, resolved_by)
|
|
52
|
+
|
|
53
|
+
## When to reach for what
|
|
54
|
+
- Unfamiliar area → `seam query "<concept>" --quiet`
|
|
55
|
+
- Before editing X → `seam impact X --json --lean` (d=1 = WILL_BREAK)
|
|
56
|
+
- Trace how A reaches B → `seam trace A B`
|
|
57
|
+
- Before committing → `seam changes`
|
|
58
|
+
- Map repo / entrypoints → `seam structure --quiet` · `seam flows`
|
|
59
|
+
|
|
60
|
+
Prefer Seam over grep/read for: "who calls X", "what breaks if I change X",
|
|
61
|
+
"where is X defined", "what's in this area".
|
|
62
|
+
|
|
63
|
+
## MCP alternative
|
|
64
|
+
Same answers as native MCP tools if you prefer tool-calling: `seam install --with-mcp`.
|
|
65
|
+
The CLI is the default because it costs fewer tokens."""
|
|
66
|
+
|
|
67
|
+
# Thin always-loaded pointer for CLAUDE.md (Claude does not read AGENTS.md).
|
|
68
|
+
_CLAUDE_HOOK = """\
|
|
69
|
+
## Seam — this repo is indexed for code intelligence
|
|
70
|
+
For structural questions (callers, blast radius, definitions, repo map) use the `seam`
|
|
71
|
+
CLI instead of grep — start with `seam <cmd> --quiet`. Full usage: the `seam` skill.
|
|
72
|
+
If `.seam/seam.db` is missing run `seam init`; if `seam status` is stale run `seam sync`."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_skill() -> str:
|
|
76
|
+
"""Full `.claude/skills/seam/SKILL.md` — YAML frontmatter + the guide body."""
|
|
77
|
+
return (
|
|
78
|
+
"---\n"
|
|
79
|
+
"name: seam\n"
|
|
80
|
+
f"description: '{_DESCRIPTION}'\n"
|
|
81
|
+
f"when_to_use: '{_WHEN_TO_USE}'\n"
|
|
82
|
+
"---\n\n"
|
|
83
|
+
f"{_GUIDE_BODY}\n"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_cursor_rule() -> str:
|
|
88
|
+
"""Full `.cursor/rules/seam.mdc` — "Agent Requested" frontmatter + the guide body.
|
|
89
|
+
|
|
90
|
+
`alwaysApply: false` + a `description` + empty `globs` is the doc-confirmed
|
|
91
|
+
recipe for a description-surfaced (progressive) Cursor rule.
|
|
92
|
+
"""
|
|
93
|
+
return (
|
|
94
|
+
"---\n"
|
|
95
|
+
f"description: '{_DESCRIPTION}'\n"
|
|
96
|
+
"globs:\n"
|
|
97
|
+
"alwaysApply: false\n"
|
|
98
|
+
"---\n\n"
|
|
99
|
+
f"{_GUIDE_BODY}\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_codex_block() -> str:
|
|
104
|
+
"""Block content for Codex `AGENTS.md` — the full guide (no progressive option)."""
|
|
105
|
+
return _GUIDE_BODY
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_claude_hook() -> str:
|
|
109
|
+
"""Block content for the thin `CLAUDE.md` discovery pointer."""
|
|
110
|
+
return _CLAUDE_HOOK
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""LEAF: pure JSON config-file operations for the installer (stdlib only).
|
|
2
|
+
|
|
3
|
+
Used by the JSON-format targets (Claude `.mcp.json`, Cursor `.cursor/mcp.json`).
|
|
4
|
+
No Seam dependencies, so targets compose these without import cycles. Every
|
|
5
|
+
write is atomic (temp + os.replace) because a half-written agent config is
|
|
6
|
+
silently skipped at the agent's startup — a crash mid-write must never corrupt it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_json(path: Path) -> dict[str, Any] | None:
|
|
17
|
+
"""Parse the JSON object at `path`, or None if absent OR unparseable.
|
|
18
|
+
|
|
19
|
+
None is intentionally ambiguous between "absent" and "corrupt" — the caller
|
|
20
|
+
disambiguates with `path.exists()` so it can back up a corrupt file before
|
|
21
|
+
overwriting it (see core._backup_if_corrupt).
|
|
22
|
+
"""
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
28
|
+
return None
|
|
29
|
+
# A top-level non-object (list/scalar) is not a config we can merge into.
|
|
30
|
+
return data if isinstance(data, dict) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
|
34
|
+
"""Write `data` as pretty JSON to `path` atomically; create parent dirs."""
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
39
|
+
json.dump(data, handle, indent=2)
|
|
40
|
+
handle.write("\n")
|
|
41
|
+
os.replace(tmp, path)
|
|
42
|
+
except BaseException:
|
|
43
|
+
# Never leave the temp file behind on failure (disk-full, signal, etc.).
|
|
44
|
+
Path(tmp).unlink(missing_ok=True)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_in(data: dict[str, Any], key_path: list[str]) -> Any:
|
|
49
|
+
"""Return the value at the nested `key_path`, or None if any segment is missing."""
|
|
50
|
+
cur: Any = data
|
|
51
|
+
for key in key_path:
|
|
52
|
+
if not isinstance(cur, dict) or key not in cur:
|
|
53
|
+
return None
|
|
54
|
+
cur = cur[key]
|
|
55
|
+
return cur
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_in(data: dict[str, Any], key_path: list[str], value: Any) -> None:
|
|
59
|
+
"""Set `value` at the nested `key_path`, creating intermediate dicts as needed.
|
|
60
|
+
|
|
61
|
+
A non-dict found at an intermediate segment is replaced with a dict — the
|
|
62
|
+
target key paths (mcpServers / projects.<root>.mcpServers) own their subtree.
|
|
63
|
+
"""
|
|
64
|
+
cur = data
|
|
65
|
+
for key in key_path[:-1]:
|
|
66
|
+
nxt = cur.get(key)
|
|
67
|
+
if not isinstance(nxt, dict):
|
|
68
|
+
nxt = {}
|
|
69
|
+
cur[key] = nxt
|
|
70
|
+
cur = nxt
|
|
71
|
+
cur[key_path[-1]] = value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delete_in(data: dict[str, Any], key_path: list[str]) -> bool:
|
|
75
|
+
"""Delete the leaf at `key_path`. Return True iff something was removed."""
|
|
76
|
+
cur = data
|
|
77
|
+
for key in key_path[:-1]:
|
|
78
|
+
nxt = cur.get(key)
|
|
79
|
+
if not isinstance(nxt, dict):
|
|
80
|
+
return False
|
|
81
|
+
cur = nxt
|
|
82
|
+
if key_path[-1] in cur:
|
|
83
|
+
del cur[key_path[-1]]
|
|
84
|
+
return True
|
|
85
|
+
return False
|