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.
- novaya-0.1.0/.gitignore +98 -0
- novaya-0.1.0/PKG-INFO +83 -0
- novaya-0.1.0/README-ngraph.md +64 -0
- novaya-0.1.0/novaya/__init__.py +13 -0
- novaya-0.1.0/novaya/__main__.py +11 -0
- novaya-0.1.0/novaya/adapters/__init__.py +53 -0
- novaya-0.1.0/novaya/adapters/base.py +236 -0
- novaya-0.1.0/novaya/adapters/claude_code.py +68 -0
- novaya-0.1.0/novaya/adapters/codex.py +94 -0
- novaya-0.1.0/novaya/adapters/opencode.py +80 -0
- novaya-0.1.0/novaya/catalog.py +167 -0
- novaya-0.1.0/novaya/cli.py +511 -0
- novaya-0.1.0/novaya/credentials.py +302 -0
- novaya-0.1.0/novaya/docs.py +286 -0
- novaya-0.1.0/novaya/doctor.py +209 -0
- novaya-0.1.0/novaya/mcp.py +178 -0
- novaya-0.1.0/novaya/service.py +84 -0
- novaya-0.1.0/novaya/transport.py +136 -0
- novaya-0.1.0/novaya/workspace.py +146 -0
- novaya-0.1.0/pyproject.toml +54 -0
novaya-0.1.0/.gitignore
ADDED
|
@@ -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 [])]
|