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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. 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
+ ]
@@ -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
+ ]
@@ -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))
@@ -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())]
@@ -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