@munhq/codeindex 0.3.2

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.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @munhq/codeindex
2
+
3
+ Structural code intelligence for AI coding agents, as an MCP server. It answers
4
+ "where is X defined", "show me this function", "what calls it" and "what breaks
5
+ if I change this file" in tens of tokens, instead of the ~1,563 a file read
6
+ costs. tree-sitter across 40+ languages, 16 MCP tools, MIT.
7
+
8
+ ```
9
+ npx -y @munhq/codeindex
10
+ ```
11
+
12
+ No account, no API key, no configuration.
13
+
14
+ ## Add it to a client
15
+
16
+ Claude Code:
17
+
18
+ ```
19
+ claude mcp add codeindex -- npx -y @munhq/codeindex
20
+ ```
21
+
22
+ Anything that reads a JSON config (Claude Desktop, Cursor, Windsurf, Zed, Cline):
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "codeindex": {
28
+ "command": "npx",
29
+ "args": ["-y", "@munhq/codeindex"]
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ ## Why this is an npm package when the server is not JavaScript
36
+
37
+ codeindex is a statically linked binary — no runtime, no node_modules, about
38
+ 53 MB per platform. This package is a 4 KB wrapper: on install it resolves the
39
+ release asset for your platform, verifies it against the `SHA256SUMS` published
40
+ beside it, caches it under `~/.cache/codeindex/bin/codeindex-<version>`, and
41
+ executes it. Six platforms are covered: linux and macOS on x86_64 and arm64,
42
+ Windows on x86_64 and arm64.
43
+
44
+ The cache key carries the version on purpose. An unversioned cache silently keeps
45
+ running an old binary after an upgrade, which is a bug this project has already
46
+ paid for once.
47
+
48
+ `CODEINDEX_BIN` overrides everything, for a local build. `PATH` is deliberately
49
+ *not* searched: this package declares one version to the MCP registry, and
50
+ running whatever `codeindex` happens to be on `PATH` would make that a lie.
51
+
52
+ ## What the tools cost
53
+
54
+ | tool | tokens per call |
55
+ |---|---|
56
+ | `Read` (for comparison) | 1,563 |
57
+ | `get_outline` | 113 |
58
+ | `find_symbol` | 102 |
59
+ | `search` | 79 |
60
+ | `find_word` | 41 |
61
+ | `read_symbol` | **35** |
62
+
63
+ ## Everything else
64
+
65
+ Source, the other install paths (`curl \| bash`, a Claude Code plugin with the
66
+ skill, a prebuilt binary), the 16 tools and the 16 analyses:
67
+ **https://github.com/munhq/codeindex**
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // The MCP server entry point: resolve the binary, then become it.
3
+ //
4
+ // `npx -y @munhq/codeindex` lands here. Every argument is passed straight
5
+ // through, so `--workspace`, `--mcp` and anything the server grows later work
6
+ // without this file knowing about them.
7
+ 'use strict';
8
+
9
+ const { spawnSync } = require('child_process');
10
+ const { resolveBinary, log } = require('./resolve.js');
11
+
12
+ (async () => {
13
+ let bin;
14
+ try {
15
+ bin = await resolveBinary();
16
+ } catch (err) {
17
+ log(`could not start: ${err.message}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ // The client speaks JSON-RPC over this process's stdio, so the child inherits
22
+ // all three streams untouched. No pipe, no buffering, no wrapper protocol.
23
+ const args = process.argv.slice(2);
24
+ // `--mcp` is what the server needs to speak MCP at all. A client that
25
+ // launches the bin with no arguments gets the CLI's help text on stdout and a
26
+ // handshake that never completes, which reads as "the server is broken".
27
+ if (!args.includes('--mcp')) args.unshift('--mcp');
28
+
29
+ const res = spawnSync(bin, args, { stdio: 'inherit' });
30
+ if (res.error) {
31
+ log(`failed to exec ${bin}: ${res.error.message}`);
32
+ process.exit(1);
33
+ }
34
+ // Relay the child's fate exactly: a signal death must not look like exit 0.
35
+ if (res.signal) process.kill(process.pid, res.signal);
36
+ process.exit(res.status === null ? 1 : res.status);
37
+ })();
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Fetch the binary at install time, so the first MCP handshake does not wait for
3
+ // a 53 MB download and time out inside the client.
4
+ //
5
+ // This is allowed to fail. A CI sandbox with no network, or `npm install
6
+ // --ignore-scripts`, must still produce a working install — codeindex-mcp.js
7
+ // downloads on demand when the cache is empty. So the exit code is always 0 and
8
+ // the reason goes to stderr, where it is visible rather than swallowed.
9
+ 'use strict';
10
+
11
+ const { resolveBinary, log } = require('./resolve.js');
12
+
13
+ resolveBinary()
14
+ .then(() => process.exit(0))
15
+ .catch((err) => {
16
+ log(`prefetch skipped: ${err.message}`);
17
+ log('the binary will be fetched on first run instead');
18
+ process.exit(0);
19
+ });
package/bin/resolve.js ADDED
@@ -0,0 +1,135 @@
1
+ // Resolve a codeindex binary for this platform, downloading the pinned release
2
+ // asset when the cache does not already hold it.
3
+ //
4
+ // Why an npm package at all, for a program that is not JavaScript: every MCP
5
+ // directory installs servers the way npm installs them. The official registry
6
+ // validates an npm version, Smithery runs `npx`, and mcp.so lists the same
7
+ // command. A compiled server with no npm name is invisible to all three, so this
8
+ // package is the thin shim that makes `npx -y @munhq/codeindex` work — about
9
+ // 4 KB of JavaScript that fetches the 53 MB binary the release already publishes.
10
+ //
11
+ // Write NOTHING to stdout. Stdout is the MCP JSON-RPC channel; one stray line
12
+ // there makes the server look broken with no error that explains why. Every
13
+ // diagnostic goes to stderr, which the client logs.
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const https = require('https');
20
+ const crypto = require('crypto');
21
+
22
+ const REPO = 'munhq/codeindex';
23
+ const { version: VERSION } = require('../package.json');
24
+
25
+ const log = (msg) => process.stderr.write(`codeindex: ${msg}\n`);
26
+
27
+ // Map Node's platform/arch onto the asset names the release actually publishes.
28
+ // The release matrix builds Zig target triples — x86_64-linux, aarch64-linux,
29
+ // x86_64-macos, aarch64-macos, x86_64-windows, aarch64-windows — and Node agrees
30
+ // with none of them: process.platform says darwin/win32, process.arch says x64.
31
+ // The shell launcher had this exact bug against uname and every Mac 404'd, so
32
+ // the mapping is spelled out rather than assembled from the raw values.
33
+ // Pure, so plugin/test_platform.sh can drive it for every platform this package
34
+ // claims to support instead of only the one the test runs on. That test already
35
+ // holds install.sh and the shell launcher to the release matrix; this is the
36
+ // third consumer of the same naming and it drifts the same way.
37
+ function assetFor(platform, arch) {
38
+ const a = { x64: 'x86_64', arm64: 'aarch64' }[arch];
39
+ const p = { linux: 'linux', darwin: 'macos', win32: 'windows' }[platform];
40
+ if (!a || !p) return null;
41
+ return `codeindex-${a}-${p}${p === 'windows' ? '.exe' : ''}`;
42
+ }
43
+
44
+ function assetName() {
45
+ return assetFor(process.platform, process.arch);
46
+ }
47
+
48
+ function cacheDir() {
49
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
50
+ return path.join(base, 'codeindex', 'bin');
51
+ }
52
+
53
+ // The cached file carries the version. The shell launcher caches an unversioned
54
+ // `codeindex`, and this repo has already been bitten by that shape: a 0.3.0
55
+ // plugin ran a 0.2.0 binary found first on PATH for days, with both halves
56
+ // reporting their own version and nobody comparing them. A version in the name
57
+ // makes an upgrade a cache miss instead of a silent downgrade.
58
+ function cachedBinary() {
59
+ const exe = process.platform === 'win32' ? '.exe' : '';
60
+ return path.join(cacheDir(), `codeindex-${VERSION}${exe}`);
61
+ }
62
+
63
+ function get(url, redirects = 0) {
64
+ return new Promise((resolve, reject) => {
65
+ if (redirects > 5) return reject(new Error(`too many redirects for ${url}`));
66
+ https
67
+ .get(url, { headers: { 'user-agent': `codeindex-npm/${VERSION}` } }, (res) => {
68
+ // GitHub serves release assets as a redirect to object storage.
69
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
70
+ res.resume();
71
+ return resolve(get(res.headers.location, redirects + 1));
72
+ }
73
+ if (res.statusCode !== 200) {
74
+ res.resume();
75
+ return reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`));
76
+ }
77
+ const chunks = [];
78
+ res.on('data', (c) => chunks.push(c));
79
+ res.on('end', () => resolve(Buffer.concat(chunks)));
80
+ res.on('error', reject);
81
+ })
82
+ .on('error', reject);
83
+ });
84
+ }
85
+
86
+ // The checksum published beside the binary is not proof on its own — whoever can
87
+ // replace one can replace the other. It is here to catch a truncated download
88
+ // and a mismatched tag, which are the failures that actually happen, and to make
89
+ // the pin auditable: both files come from the tag this package version names.
90
+ async function verifiedDownload(asset) {
91
+ const base = `https://github.com/${REPO}/releases/download/v${VERSION}`;
92
+ log(`fetching ${asset} for v${VERSION} (about 53 MB, once per version)`);
93
+
94
+ const sums = (await get(`${base}/SHA256SUMS`)).toString('utf8');
95
+ const line = sums.split('\n').find((l) => l.trim().endsWith(` ${asset}`));
96
+ if (!line) throw new Error(`SHA256SUMS for v${VERSION} does not list ${asset}`);
97
+ const want = line.trim().split(/\s+/)[0];
98
+
99
+ const body = await get(`${base}/${asset}`);
100
+ const got = crypto.createHash('sha256').update(body).digest('hex');
101
+ if (got !== want) throw new Error(`checksum mismatch for ${asset}: want ${want}, got ${got}`);
102
+
103
+ const dest = cachedBinary();
104
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
105
+ // Write then rename, so a killed download never leaves a half file that the
106
+ // next run treats as installed.
107
+ const tmp = `${dest}.tmp-${process.pid}`;
108
+ fs.writeFileSync(tmp, body, { mode: 0o755 });
109
+ fs.renameSync(tmp, dest);
110
+ log(`installed ${dest}`);
111
+ return dest;
112
+ }
113
+
114
+ // An explicit override wins, for a local build or an unusual layout. PATH is
115
+ // deliberately NOT consulted: this package declares one version to the registry,
116
+ // and running whatever `codeindex` happens to be on PATH would make that
117
+ // declaration a lie.
118
+ async function resolveBinary() {
119
+ const override = process.env.CODEINDEX_BIN;
120
+ if (override && fs.existsSync(override)) return override;
121
+
122
+ const cached = cachedBinary();
123
+ if (fs.existsSync(cached)) return cached;
124
+
125
+ const asset = assetName();
126
+ if (!asset) {
127
+ throw new Error(
128
+ `no release build for ${process.platform}/${process.arch}. ` +
129
+ `Build from source: https://github.com/${REPO}#build-from-source`
130
+ );
131
+ }
132
+ return verifiedDownload(asset);
133
+ }
134
+
135
+ module.exports = { resolveBinary, cachedBinary, assetName, assetFor, VERSION, log };
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // Print the asset this wrapper would fetch on every platform it claims to
3
+ // support, one per line, as `platform<TAB>arch<TAB>asset`.
4
+ //
5
+ // Two consumers: `npm test`, which checks the table is complete and that the
6
+ // package version looks like a release tag, and plugin/test_platform.sh, which
7
+ // compares these names against the release matrix in
8
+ // .github/workflows/release.yml. The shell launcher and install.sh are already
9
+ // held to that matrix; this wrapper is the third place the same mapping is
10
+ // written, and the Mac 404 it caused once is the reason all three are tested.
11
+ 'use strict';
12
+
13
+ const { assetFor, VERSION } = require('./resolve.js');
14
+
15
+ const SUPPORTED = [
16
+ ['linux', 'x64'],
17
+ ['linux', 'arm64'],
18
+ ['darwin', 'x64'],
19
+ ['darwin', 'arm64'],
20
+ ['win32', 'x64'],
21
+ ['win32', 'arm64'],
22
+ ];
23
+
24
+ // Platforms with no release build must resolve to nothing rather than to a
25
+ // plausible-looking name that 404s at download time.
26
+ const UNSUPPORTED = [
27
+ ['freebsd', 'x64'],
28
+ ['linux', 'ia32'],
29
+ ['sunos', 'x64'],
30
+ ];
31
+
32
+ let fail = 0;
33
+
34
+ for (const [platform, arch] of SUPPORTED) {
35
+ const asset = assetFor(platform, arch);
36
+ if (!asset) {
37
+ process.stderr.write(`FAIL ${platform}/${arch} resolves to nothing\n`);
38
+ fail++;
39
+ continue;
40
+ }
41
+ process.stdout.write(`${platform}\t${arch}\t${asset}\n`);
42
+ }
43
+
44
+ for (const [platform, arch] of UNSUPPORTED) {
45
+ const asset = assetFor(platform, arch);
46
+ if (asset) {
47
+ process.stderr.write(`FAIL ${platform}/${arch} resolves to ${asset}, but no such build exists\n`);
48
+ fail++;
49
+ }
50
+ }
51
+
52
+ // The wrapper downloads from the tag named by its own version, so a version that
53
+ // is not a plain release number can never find an asset.
54
+ if (!/^\d+\.\d+\.\d+$/.test(VERSION)) {
55
+ process.stderr.write(`FAIL version ${VERSION} is not a release version, so v${VERSION} is not a tag\n`);
56
+ fail++;
57
+ }
58
+
59
+ if (fail) {
60
+ process.stderr.write(`\n${fail} problem(s)\n`);
61
+ process.exit(1);
62
+ }
63
+ process.stderr.write(`resolver: ${SUPPORTED.length} platforms, version ${VERSION}\n`);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@munhq/codeindex",
3
+ "version": "0.3.2",
4
+ "description": "Structural code intelligence for AI coding agents over MCP: find a definition, read one function, see what calls a symbol, trace imports and judge a change's blast radius across 40+ languages \u2014 in tens of tokens instead of the ~1,563 a file read costs.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "code-intelligence",
10
+ "tree-sitter",
11
+ "ai-agents",
12
+ "claude",
13
+ "context-window"
14
+ ],
15
+ "homepage": "https://github.com/munhq/codeindex",
16
+ "bugs": "https://github.com/munhq/codeindex/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/munhq/codeindex.git"
20
+ },
21
+ "license": "MIT",
22
+ "bin": {
23
+ "codeindex-mcp": "bin/codeindex-mcp.js"
24
+ },
25
+ "scripts": {
26
+ "postinstall": "node bin/postinstall.js",
27
+ "test": "node bin/selftest.js"
28
+ },
29
+ "files": [
30
+ "bin/",
31
+ "README.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }