novaya 0.1.0__tar.gz

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.
@@ -0,0 +1,98 @@
1
+ user.md
2
+ credentials.md
3
+ # ...but NOT the generated setup guide of that name. The rule above is
4
+ # unanchored, so it matches `credentials.md` at ANY depth — which silently
5
+ # swallowed novaya-deploy/setup/credentials.md, a PUBLIC document every setup
6
+ # guide links to. It generated fine, passed tests (they read the disk) and
7
+ # 404'd in production, because it never reached git at all.
8
+ !novaya-deploy/setup/credentials.md
9
+ .env
10
+ data/
11
+ __pycache__/
12
+ *.pyc
13
+ connectors/*/auth.json
14
+
15
+ # Runtime / agent-generated artifacts (not source)
16
+ workspace/
17
+ .gstack/
18
+ .nexus_server.pid
19
+ *.log
20
+ =*
21
+ dist/
22
+ build/
23
+ # Generated by packaging/build_npm_package.py — a verbatim copy of the manifest's
24
+ # npm distribution, rmtree'd and rebuilt on every run. Ignored so it is neither
25
+ # committed nor indexed: it duplicated every symbol in the knowledge graph and
26
+ # made impact analysis report doubled blast radius.
27
+ npm/payload/
28
+
29
+ # Same reasoning, same failure mode: a verbatim copy of sdk/python/codewiki/,
30
+ # rebuilt by novaya-installer/scripts/sync-payload.js on every pack.
31
+ novaya-installer/payload/
32
+ node_modules/
33
+
34
+ # A LOCAL OVERRIDE for render.yaml, so there is somewhere safe to put values
35
+ # while working on the deploy. render.yaml ITSELF is deliberately NOT ignored:
36
+ # Render reads it out of the repository to build the service, so ignoring it
37
+ # would stop the deploy working at all — and ignoring a file git already tracks
38
+ # does not untrack it or remove anything already committed, so it would buy no
39
+ # safety either. Real credentials belong in the Render dashboard, which is why
40
+ # every secret in render.yaml is declared `sync: false` with no value.
41
+ render.local.yaml
42
+ render.*.local.yaml
43
+
44
+ config/google_oauth.json
45
+ config/github_oauth.json
46
+ config/airtable_oauth.json
47
+ config/supabase_oauth.json
48
+ config/huggingface_oauth.json
49
+ config/twitter_oauth.json
50
+ config/netlify_oauth.json
51
+ config/digitalocean_oauth.json
52
+ config/heroku_oauth.json
53
+ config/gitlab_oauth.json
54
+ config/dropbox_oauth.json
55
+ config/asana_oauth.json
56
+
57
+ # npm pack output — a build artifact, rebuilt by `npm pack` on demand.
58
+ *.tgz
59
+
60
+ # Large binaries that no page references — verified with grep before removal, and
61
+ # untracked with `git rm --cached`, so every one is still on disk. The repo is
62
+ # about to be pushed to GitHub for Render to deploy from; 72MB of unreferenced
63
+ # video makes every clone slower forever and buys nothing.
64
+ #
65
+ # nexus-hero.mp4 (7MB) IS referenced by novaya-deploy/index.html and stays.
66
+ # nexus-hero.mp4.mp4 is a 60MB duplicate with a doubled extension.
67
+ novaya-deploy/nexus-hero.mp4.mp4
68
+ Nexus codewiki - Trim.mp4
69
+ Nexus codewiki - Trim 2.mp4
70
+ # The extracted copy is tracked alongside these, so the archives are the same
71
+ # bytes twice.
72
+ design_extracted/design.tar
73
+ design_extracted/design.tar.gz
74
+
75
+ # RUNTIME STATE, not source. core/skill_library.py's _write_stats() rewrites
76
+ # one of these after EVERY skill invocation (success_rate, invocation_count,
77
+ # last_used), so tracking them meant 264 files churning on every Nexus run and
78
+ # turning up as diff noise in unrelated commits. Deleting them never stuck —
79
+ # the next run recreates them — and a .gitignore rule alone could not help
80
+ # while git still tracked them (see the render.local.yaml note above for the
81
+ # same trap). They are untracked with `git rm --cached`, so all 264 stay on
82
+ # disk and no counter is lost.
83
+ #
84
+ # Safe to be absent on a fresh clone: _load_meta() backfills `name` from the
85
+ # skill's directory and `description` from its SKILL.md, and every counter has
86
+ # a default — verified by loading a skill with its stats.json removed.
87
+ agents/*/skills/*/stats.json
88
+
89
+ # Per-machine Claude Code settings — was tracked AND matched by an ignore rule,
90
+ # which is the one combination where .gitignore silently does nothing.
91
+ .claude/settings.local.json
92
+
93
+ # installClaudeCode writes project-scoped MCP config to process.cwd(), so any
94
+ # test or manual run from novaya-installer/ drops one here. It is a per-project
95
+ # artifact of whoever ran the installer, never something to ship.
96
+ novaya-installer/.mcp.json
97
+ .mcp.json
98
+ .coldtest/
novaya-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: novaya
3
+ Version: 0.1.0
4
+ Summary: Ngraph: the Codewiki client. Repository context for coding agents.
5
+ Project-URL: Homepage, https://trynovaya.com
6
+ Project-URL: Dashboard, https://app.trynovaya.com
7
+ Author: Novaya
8
+ License: Proprietary
9
+ Keywords: codewiki,coding-agents,context,mcp,ngraph
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Documentation
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Ngraph
21
+
22
+ The client half of [Codewiki](https://trynovaya.com). Codewiki is a hosted
23
+ knowledge graph over your codebase; Ngraph is the small thing you install so
24
+ your coding agents can reach it.
25
+
26
+ ```sh
27
+ uv tool install novaya
28
+ ngraph install <KEY> # key from https://app.trynovaya.com -> Manage keys
29
+ ```
30
+
31
+ That is the whole setup. `install` stores the key in your OS credential store,
32
+ resolves which indexed codebase this checkout is, writes `.ngraph/`, wires
33
+ every supported agent on the machine, and then proves it worked. If it prints
34
+ `Done`, a fresh session in this repository can already use it.
35
+
36
+ ## What it wires
37
+
38
+ | Client | Instructions | MCP |
39
+ | --- | --- | --- |
40
+ | Claude Code | `CLAUDE.md` | `~/.claude.json` |
41
+ | Codex | `AGENTS.md` | `~/.codex/config.toml` |
42
+ | OpenCode | `AGENTS.md` | `~/.config/opencode/opencode.json` |
43
+
44
+ Each gets the MCP server registered natively **and** a short delimited block in
45
+ its instruction file pointing at `.ngraph/rules.md`. Both, because MCP gives an
46
+ agent real tools and only reaches clients that speak it, while the pointer
47
+ reaches anything that reads a file.
48
+
49
+ ## Commands
50
+
51
+ ```
52
+ ngraph summary what this project is, in prose
53
+ ngraph overview computed architecture: hubs, subsystems, layers
54
+ ngraph why <path> the recorded reasoning behind a file
55
+ ngraph connections <path> what it is wired to, including historical co-change
56
+ ngraph impact <path> blast radius
57
+ ngraph recent recent commits with the intent behind them
58
+ ngraph search <query> find by meaning
59
+ ngraph ask "<question>" a decision-shaped briefing
60
+ ngraph record-why "<why>" write reasoning back after a commit
61
+
62
+ ngraph doctor prove the setup still works (exit code for CI)
63
+ ngraph adapters which clients this knows how to wire
64
+ ngraph uninstall remove everything it wrote
65
+ ```
66
+
67
+ ## Where things live
68
+
69
+ - **Key** -- your OS credential store (DPAPI, Keychain, libsecret, or a 0600
70
+ file). Never in the repository, never in a `.md`, never printed.
71
+ - **`.ngraph/rules.md`** -- how to work with the graph. Same in every repo.
72
+ - **`.ngraph/skills.md`** -- what the server serves, generated from the live API.
73
+ - **`.ngraph/blueprint.md`** -- this repository: its codebase name and origin.
74
+
75
+ Commit `.ngraph/`. It is generated but stable, and sharing it means your
76
+ teammates' agents resolve the same codebase you did.
77
+
78
+ ## Why the package is `novaya` and the command is `ngraph`
79
+
80
+ `ngraph` on PyPI is an active network-modeling library and `codewiki` is an
81
+ unrelated project. Neither name is available, and installing either gets you
82
+ working software that is not this. `uv tool install novaya` is the only
83
+ install line.
@@ -0,0 +1,64 @@
1
+ # Ngraph
2
+
3
+ The client half of [Codewiki](https://trynovaya.com). Codewiki is a hosted
4
+ knowledge graph over your codebase; Ngraph is the small thing you install so
5
+ your coding agents can reach it.
6
+
7
+ ```sh
8
+ uv tool install novaya
9
+ ngraph install <KEY> # key from https://app.trynovaya.com -> Manage keys
10
+ ```
11
+
12
+ That is the whole setup. `install` stores the key in your OS credential store,
13
+ resolves which indexed codebase this checkout is, writes `.ngraph/`, wires
14
+ every supported agent on the machine, and then proves it worked. If it prints
15
+ `Done`, a fresh session in this repository can already use it.
16
+
17
+ ## What it wires
18
+
19
+ | Client | Instructions | MCP |
20
+ | --- | --- | --- |
21
+ | Claude Code | `CLAUDE.md` | `~/.claude.json` |
22
+ | Codex | `AGENTS.md` | `~/.codex/config.toml` |
23
+ | OpenCode | `AGENTS.md` | `~/.config/opencode/opencode.json` |
24
+
25
+ Each gets the MCP server registered natively **and** a short delimited block in
26
+ its instruction file pointing at `.ngraph/rules.md`. Both, because MCP gives an
27
+ agent real tools and only reaches clients that speak it, while the pointer
28
+ reaches anything that reads a file.
29
+
30
+ ## Commands
31
+
32
+ ```
33
+ ngraph summary what this project is, in prose
34
+ ngraph overview computed architecture: hubs, subsystems, layers
35
+ ngraph why <path> the recorded reasoning behind a file
36
+ ngraph connections <path> what it is wired to, including historical co-change
37
+ ngraph impact <path> blast radius
38
+ ngraph recent recent commits with the intent behind them
39
+ ngraph search <query> find by meaning
40
+ ngraph ask "<question>" a decision-shaped briefing
41
+ ngraph record-why "<why>" write reasoning back after a commit
42
+
43
+ ngraph doctor prove the setup still works (exit code for CI)
44
+ ngraph adapters which clients this knows how to wire
45
+ ngraph uninstall remove everything it wrote
46
+ ```
47
+
48
+ ## Where things live
49
+
50
+ - **Key** -- your OS credential store (DPAPI, Keychain, libsecret, or a 0600
51
+ file). Never in the repository, never in a `.md`, never printed.
52
+ - **`.ngraph/rules.md`** -- how to work with the graph. Same in every repo.
53
+ - **`.ngraph/skills.md`** -- what the server serves, generated from the live API.
54
+ - **`.ngraph/blueprint.md`** -- this repository: its codebase name and origin.
55
+
56
+ Commit `.ngraph/`. It is generated but stable, and sharing it means your
57
+ teammates' agents resolve the same codebase you did.
58
+
59
+ ## Why the package is `novaya` and the command is `ngraph`
60
+
61
+ `ngraph` on PyPI is an active network-modeling library and `codewiki` is an
62
+ unrelated project. Neither name is available, and installing either gets you
63
+ working software that is not this. `uv tool install novaya` is the only
64
+ install line.
@@ -0,0 +1,13 @@
1
+ """Ngraph -- the client half of Codewiki.
2
+
3
+ Cloud owns indexing, the graph, retrieval and the API. This owns auth,
4
+ discovery, agent adapters and request transport. Nothing else, ever: see
5
+ build.md, LAW 2.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ # The typed command. Distribution is `novaya`: `ngraph` and `codewiki` are both
12
+ # taken on PyPI by live unrelated projects, so a guess installs the wrong tool.
13
+ COMMAND = "ngraph"
@@ -0,0 +1,11 @@
1
+ """`python -m novaya` -- the entrypoint a client config falls back to.
2
+
3
+ The console script is the normal path. This exists because an MCP config has to
4
+ name something that will still resolve when the client launches it from an
5
+ environment that is not the user's shell -- and on a checkout, or a PATH the
6
+ resolver cannot see, the module is the thing that always works.
7
+ """
8
+ from .cli import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ """The adapter registry. One declaration.
2
+
3
+ install, doctor, `ngraph adapters` and the docs all read ADAPTERS. Three ship:
4
+ each one is a claim that somebody checked where that client keeps its config.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .base import Adapter, Result, mcp_command, probe_server, SERVER_NAME
9
+ from .claude_code import ClaudeCode
10
+ from .codex import Codex
11
+ from .opencode import OpenCode
12
+
13
+ ADAPTERS = (ClaudeCode(), Codex(), OpenCode())
14
+
15
+ SLUGS = tuple(a.slug for a in ADAPTERS)
16
+
17
+
18
+ def get(slug: str):
19
+ for adapter in ADAPTERS:
20
+ if adapter.slug == slug:
21
+ return adapter
22
+ return None
23
+
24
+
25
+ def detected():
26
+ """The clients actually present on this machine."""
27
+ return [a for a in ADAPTERS if a.detect()]
28
+
29
+
30
+ def resolve(names):
31
+ """Names from the command line to adapters.
32
+
33
+ ("all",) or nothing means "whatever is installed here" -- an install should
34
+ not wire a client the user does not have, and should not need to be told
35
+ which ones they do.
36
+ """
37
+ wanted = [n.strip().lower() for n in (names or []) if n and n.strip()]
38
+ if not wanted or "auto" in wanted:
39
+ return detected(), []
40
+ if "all" in wanted:
41
+ return list(ADAPTERS), []
42
+ chosen, unknown = [], []
43
+ for name in wanted:
44
+ adapter = get(name)
45
+ if adapter is None:
46
+ unknown.append(name)
47
+ elif adapter not in chosen:
48
+ chosen.append(adapter)
49
+ return chosen, unknown
50
+
51
+
52
+ __all__ = ["ADAPTERS", "SLUGS", "Adapter", "Result", "get", "detected",
53
+ "resolve", "mcp_command", "probe_server", "SERVER_NAME"]
@@ -0,0 +1,236 @@
1
+ """What an adapter is: native MCP config, plus a pointer block.
2
+
3
+ Neither alone reaches every client. Rules all of them follow -- read-modify-
4
+ write a user's config, replace between markers rather than append, warn rather
5
+ than crash on a config we cannot parse, and place a command, never a key.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from .. import docs
18
+
19
+ # One name everywhere. Changing it orphans every config already written.
20
+ SERVER_NAME = "codewiki"
21
+
22
+ BLOCK_START = "# ngraph:start"
23
+ BLOCK_END = "# ngraph:end"
24
+
25
+
26
+ @dataclass
27
+ class Result:
28
+ """What one adapter did, in terms an install report can print."""
29
+ slug: str
30
+ name: str
31
+ detected: bool = False
32
+ actions: list = field(default_factory=list)
33
+ warnings: list = field(default_factory=list)
34
+
35
+ @property
36
+ def ok(self) -> bool:
37
+ return self.detected and not self.warnings
38
+
39
+ def act(self, text: str):
40
+ self.actions.append(text)
41
+
42
+ def warn(self, text: str):
43
+ self.warnings.append(text)
44
+
45
+
46
+ def mcp_command() -> list:
47
+ """How a client launches our server. The shim, ABSOLUTE: a client spawns
48
+ MCP servers from its own environment, which often has no ~/.local/bin."""
49
+ shim = shutil.which("ngraph")
50
+ if shim:
51
+ return [str(Path(shim).resolve()), "mcp"]
52
+ # A checkout, or a PATH we cannot see. The module needs no shim.
53
+ return [sys.executable, "-m", "novaya", "mcp"]
54
+
55
+
56
+ def home() -> Path:
57
+ return Path.home()
58
+
59
+
60
+ def config_home() -> Path:
61
+ """XDG config root, with the Windows equivalent."""
62
+ if sys.platform.startswith("win"):
63
+ return Path(os.environ.get("APPDATA") or (home() / "AppData" / "Roaming"))
64
+ return Path(os.environ.get("XDG_CONFIG_HOME") or (home() / ".config"))
65
+
66
+
67
+ def read_json(path: Path):
68
+ """(document, error). A missing file is an empty document, not an error."""
69
+ if not path.exists():
70
+ return {}, ""
71
+ try:
72
+ text = path.read_text(encoding="utf-8")
73
+ except OSError as exc:
74
+ return None, "could not read " + str(path) + ": " + exc.strerror
75
+ if not text.strip():
76
+ return {}, ""
77
+ try:
78
+ doc = json.loads(text)
79
+ except json.JSONDecodeError as exc:
80
+ return None, "not valid JSON (" + str(exc) + "): " + str(path)
81
+ if not isinstance(doc, dict):
82
+ return None, "expected a JSON object at the top level: " + str(path)
83
+ return doc, ""
84
+
85
+
86
+ def write_json(path: Path, doc: dict) -> str:
87
+ """Write a config back; a crash cannot truncate it."""
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp = path.with_suffix(path.suffix + ".ngraph-tmp")
90
+ tmp.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8")
91
+ os.replace(tmp, path)
92
+ return str(path)
93
+
94
+
95
+ def set_block(path: Path, body: str) -> str:
96
+ """Replace our block in a line-based config. No TOML writer in the stdlib,
97
+ and a round-trip parser would eat the user's comments."""
98
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
99
+ block = BLOCK_START + "\n" + body.strip() + "\n" + BLOCK_END
100
+ if BLOCK_START in original and BLOCK_END in original:
101
+ head = original.split(BLOCK_START)[0]
102
+ tail = original.split(BLOCK_END, 1)[1]
103
+ updated = head + block + tail
104
+ elif original.strip():
105
+ updated = original.rstrip("\n") + "\n\n" + block + "\n"
106
+ else:
107
+ updated = block + "\n"
108
+ if updated == original:
109
+ return "unchanged"
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ path.write_text(updated, encoding="utf-8")
112
+ return "updated" if original else "created"
113
+
114
+
115
+ def drop_block(path: Path) -> bool:
116
+ if not path.exists():
117
+ return False
118
+ original = path.read_text(encoding="utf-8")
119
+ if BLOCK_START not in original or BLOCK_END not in original:
120
+ return False
121
+ head = original.split(BLOCK_START)[0]
122
+ tail = original.split(BLOCK_END, 1)[1]
123
+ path.write_text((head.rstrip("\n") + "\n" + tail.lstrip("\n")).strip() + "\n",
124
+ encoding="utf-8")
125
+ return True
126
+
127
+
128
+ def on_path(binary: str) -> bool:
129
+ return shutil.which(binary) is not None
130
+
131
+
132
+ def command_problem(argv) -> str:
133
+ """'' when argv[0] is a launchable program, else why not."""
134
+ program = str(argv[0])
135
+ if Path(program).is_absolute():
136
+ return "" if Path(program).exists() else (
137
+ "registered command no longer exists: " + program
138
+ + " -- re-run `ngraph install`")
139
+ if shutil.which(program):
140
+ return ""
141
+ return "registered command is not on PATH: " + program
142
+
143
+
144
+ class Adapter:
145
+ """One coding client. Subclasses supply paths and a config shape."""
146
+
147
+ slug = ""
148
+ name = ""
149
+ instruction_file = "AGENTS.md"
150
+ docs_url = ""
151
+
152
+ def detect(self) -> bool:
153
+ raise NotImplementedError
154
+
155
+ def register_mcp(self, result: Result):
156
+ """Put our server in the client's own config. Optional per client."""
157
+ result.act("no MCP registration for this client -- shell verbs only")
158
+
159
+ def unregister_mcp(self, result: Result):
160
+ pass
161
+
162
+ # -- the half every adapter shares ----------------------------------------
163
+
164
+ def wire(self, root: Path, codebase: str = "", with_mcp: bool = True) -> Result:
165
+ result = Result(self.slug, self.name, detected=True)
166
+ path = Path(root) / self.instruction_file
167
+ state = docs.apply_pointer(path, codebase)
168
+ result.act(state + " pointer block in " + self.instruction_file)
169
+ if not with_mcp:
170
+ # Claude Code blocks startup on an MCP server that never
171
+ # answers: registering a broken one hangs it, not degrades it.
172
+ result.warn("MCP not registered -- the server did not answer a "
173
+ "handshake (see the mcp server check)")
174
+ return result
175
+ try:
176
+ self.register_mcp(result)
177
+ except OSError as exc:
178
+ result.warn("MCP registration failed: " + str(exc))
179
+ return result
180
+
181
+ def unwire(self, root: Path) -> Result:
182
+ result = Result(self.slug, self.name, detected=True)
183
+ if docs.remove_pointer(Path(root) / self.instruction_file):
184
+ result.act("removed pointer block from " + self.instruction_file)
185
+ try:
186
+ self.unregister_mcp(result)
187
+ except OSError as exc:
188
+ result.warn("MCP removal failed: " + str(exc))
189
+ return result
190
+
191
+ def recorded_command(self):
192
+ """The argv this client's config will actually launch, or None."""
193
+ return None
194
+
195
+ def verify_mcp(self) -> str:
196
+ """'' when this client can really start our server, else why not.
197
+
198
+ Presence of the key is not enough. An upgrade that moves the shim
199
+ leaves a config naming a path that no longer exists -- the client gets
200
+ nothing and every other check still passes, which is the silent
201
+ success this whole design exists to remove.
202
+ """
203
+ argv = self.recorded_command()
204
+ if argv is None:
205
+ return "not registered"
206
+ if not argv:
207
+ return "registered with an empty command"
208
+ return command_problem(argv)
209
+
210
+
211
+ def probe_server(timeout: float = 25.0) -> str:
212
+ """Handshake with our own server the way a client would. Registering
213
+ proves a config has a line; this proves the line points at something."""
214
+ argv = mcp_command()
215
+ request = json.dumps({
216
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
217
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {},
218
+ "clientInfo": {"name": "ngraph-doctor", "version": "1"}},
219
+ }) + "\n"
220
+ try:
221
+ proc = subprocess.run(argv, input=request, capture_output=True,
222
+ text=True, timeout=timeout, check=False)
223
+ except (OSError, subprocess.SubprocessError) as exc:
224
+ return "could not start " + " ".join(argv) + ": " + str(exc)
225
+ for line in (proc.stdout or "").splitlines():
226
+ line = line.strip()
227
+ if not line:
228
+ continue
229
+ try:
230
+ doc = json.loads(line)
231
+ except json.JSONDecodeError:
232
+ continue
233
+ if isinstance(doc, dict) and doc.get("id") == 1 and "result" in doc:
234
+ return ""
235
+ detail = (proc.stderr or "").strip().splitlines()
236
+ return "no initialize response" + (": " + detail[-1] if detail else "")
@@ -0,0 +1,68 @@
1
+ """Claude Code.
2
+
3
+ MCP servers go in `~/.claude.json`, not settings.json -- that file ignores an
4
+ mcpServers key silently. The same file holds every project and server the user
5
+ has, so it is read-modify-write or it is data loss.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from . import base
12
+
13
+
14
+ class ClaudeCode(base.Adapter):
15
+ slug = "claude-code"
16
+ name = "Claude Code"
17
+ instruction_file = "CLAUDE.md"
18
+ docs_url = "https://code.claude.com/docs/en/mcp"
19
+
20
+ def config_path(self) -> Path:
21
+ return base.home() / ".claude.json"
22
+
23
+ def detect(self) -> bool:
24
+ return (self.config_path().exists()
25
+ or (base.home() / ".claude").is_dir()
26
+ or base.on_path("claude"))
27
+
28
+ def register_mcp(self, result: base.Result):
29
+ path = self.config_path()
30
+ doc, err = base.read_json(path)
31
+ if doc is None:
32
+ result.warn(err + " -- left untouched; shell verbs still work")
33
+ return
34
+ servers = doc.get("mcpServers")
35
+ if not isinstance(servers, dict):
36
+ if servers is not None:
37
+ result.warn("mcpServers in ~/.claude.json is not an object -- "
38
+ "left untouched")
39
+ return
40
+ servers = {}
41
+ argv = base.mcp_command()
42
+ entry = {"command": argv[0], "args": argv[1:]}
43
+ if servers.get(base.SERVER_NAME) == entry:
44
+ result.act("MCP server already registered in ~/.claude.json")
45
+ return
46
+ servers[base.SERVER_NAME] = entry
47
+ doc["mcpServers"] = servers
48
+ base.write_json(path, doc)
49
+ result.act("registered MCP server '" + base.SERVER_NAME
50
+ + "' in ~/.claude.json")
51
+
52
+ def unregister_mcp(self, result: base.Result):
53
+ path = self.config_path()
54
+ doc, err = base.read_json(path)
55
+ if doc is None or not isinstance(doc.get("mcpServers"), dict):
56
+ return
57
+ if doc["mcpServers"].pop(base.SERVER_NAME, None) is not None:
58
+ base.write_json(path, doc)
59
+ result.act("removed MCP server from ~/.claude.json")
60
+
61
+ def recorded_command(self):
62
+ doc, _err = base.read_json(self.config_path())
63
+ if doc is None:
64
+ return None
65
+ entry = (doc.get("mcpServers") or {}).get(base.SERVER_NAME)
66
+ if not isinstance(entry, dict) or not entry.get("command"):
67
+ return None
68
+ return [entry["command"], *(entry.get("args") or [])]