@adia-ai/mcp 0.8.37 → 0.8.39

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,312 @@
1
+ #!/usr/bin/env python3
2
+ """adia-info — one-shot project-context probe for adia-ui consumer repos.
3
+
4
+ Prints a compact JSON snapshot of the facts the factory skills otherwise
5
+ re-discover by hand every session: which @adia-ai/* packages the project
6
+ declares (and which version is actually installed), the rendering-mode and
7
+ framework signals app-planning's classifiers cite, which shells the source
8
+ uses, theming setup, MCP wiring, and whether the cwd is actually the
9
+ framework monorepo itself (a misroute signal — that work belongs to the
10
+ adia-ui-forge plugin).
11
+
12
+ Designed for dynamic context injection from a SKILL.md body:
13
+
14
+ !`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/adia-info"`
15
+
16
+ so every field carries the SIGNAL it was derived from (file path or dep
17
+ name) — a skill consuming this output can cite signals per app-planning's
18
+ evidence gate instead of asserting axes bare.
19
+
20
+ Contract: NEVER exits non-zero, never prints to stderr on the happy path —
21
+ a context-injection command that fails or noises breaks the skill it feeds.
22
+ Any probe error degrades that field to null with the error noted in
23
+ `probeErrors`. Bounded: scans at most MAX_SCAN_FILES source files, skipping
24
+ node_modules/dist/.git.
25
+
26
+ Usage:
27
+ adia-info [dir] # probe dir (default: cwd); print JSON to stdout
28
+ adia-info [dir] --staleness # ALSO query npm for latest (network; never default)
29
+ adia-info selftest # run built-in fixtures; exit 0 iff all pass
30
+ Stdlib only (Python 3.8+).
31
+ """
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ MAX_SCAN_FILES = 400
38
+ SOURCE_EXTS = (".html", ".js", ".mjs", ".ts", ".jsx", ".tsx", ".css")
39
+ SKIP_DIRS = {"node_modules", "dist", ".git", ".next", ".nuxt", ".svelte-kit", "build", "coverage"}
40
+
41
+ SSR_FRAMEWORKS = { # dep name -> framework label
42
+ "next": "next", "nuxt": "nuxt", "@sveltejs/kit": "sveltekit", "astro": "astro",
43
+ }
44
+ SHELL_TAGS = ("admin-shell", "chat-shell", "editor-shell", "simple-shell", "embed-shell")
45
+
46
+
47
+ def _read_json(path):
48
+ try:
49
+ with open(path, encoding="utf-8") as f:
50
+ return json.load(f)
51
+ except Exception:
52
+ return None
53
+
54
+
55
+ def _iter_source_files(root):
56
+ count = 0
57
+ for dirpath, dirnames, filenames in os.walk(root):
58
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
59
+ for name in filenames:
60
+ if name.endswith(SOURCE_EXTS):
61
+ yield os.path.join(dirpath, name)
62
+ count += 1
63
+ if count >= MAX_SCAN_FILES:
64
+ return
65
+
66
+
67
+ def probe(root):
68
+ info = {"probeRoot": os.path.abspath(root), "probeErrors": []}
69
+
70
+ # ── package.json: adia deps, framework, package manager ──
71
+ pkg = _read_json(os.path.join(root, "package.json")) or {}
72
+ deps = {}
73
+ for key in ("dependencies", "devDependencies"):
74
+ deps.update(pkg.get(key) or {})
75
+ info["adiaPackages"] = {k: v for k, v in sorted(deps.items()) if k.startswith("@adia-ai/")}
76
+
77
+ installed = _read_json(os.path.join(root, "node_modules", "@adia-ai", "web-components", "package.json"))
78
+ info["installedVersion"] = installed.get("version") if installed else None
79
+
80
+ # ── split-lockstep detection (offline): all installed @adia-ai/* versions ──
81
+ # The 11 @adia-ai packages version in lockstep upstream; a consumer whose
82
+ # installed copies diverge (partial upgrade, stale lockfile entry) sees
83
+ # cross-package breakage that presents as component bugs.
84
+ installed_versions = {}
85
+ adia_dir = os.path.join(root, "node_modules", "@adia-ai")
86
+ if os.path.isdir(adia_dir):
87
+ for name in sorted(os.listdir(adia_dir)):
88
+ p = _read_json(os.path.join(adia_dir, name, "package.json"))
89
+ if p and p.get("version"):
90
+ installed_versions[f"@adia-ai/{name}"] = p["version"]
91
+ distinct = sorted(set(installed_versions.values()))
92
+ info["installedAdiaVersions"] = installed_versions or None
93
+ info["splitLockstep"] = (
94
+ {"value": len(distinct) > 1, "versions": distinct,
95
+ "signal": "node_modules/@adia-ai/*/package.json version fields"}
96
+ if installed_versions else None
97
+ )
98
+
99
+ framework = next((label for dep, label in SSR_FRAMEWORKS.items() if dep in deps), None)
100
+ if framework is None and "vite" in deps:
101
+ framework = "vite"
102
+ info["framework"] = framework
103
+
104
+ pm = pkg.get("packageManager")
105
+ if pm:
106
+ info["packageManager"] = {"value": pm.split("@")[0], "signal": "package.json packageManager field"}
107
+ else:
108
+ for lockfile, name in (("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
109
+ ("yarn.lock", "yarn"), ("package-lock.json", "npm")):
110
+ if os.path.exists(os.path.join(root, lockfile)):
111
+ info["packageManager"] = {"value": name, "signal": lockfile}
112
+ break
113
+ else:
114
+ info["packageManager"] = None
115
+
116
+ # ── framework-monorepo misroute signal ──
117
+ info["isFrameworkMonorepo"] = (
118
+ os.path.isdir(os.path.join(root, "packages", "web-components", "components"))
119
+ and os.path.isdir(os.path.join(root, "packages", "a2ui"))
120
+ )
121
+
122
+ # ── rendering mode (app-planning classifier 1, same signals) ──
123
+ if framework in SSR_FRAMEWORKS.values():
124
+ route_dirs = [d for d in ("app", "pages", "src/routes", "src/pages") if os.path.isdir(os.path.join(root, d))]
125
+ info["renderingMode"] = {"value": "ssr", "signal": f"{framework} in package.json" + (f" + {route_dirs[0]}/" if route_dirs else "")}
126
+ elif info["adiaPackages"] or os.path.exists(os.path.join(root, "index.html")):
127
+ info["renderingMode"] = {"value": "spa", "signal": "no SSR framework dep" + ("; index.html present" if os.path.exists(os.path.join(root, "index.html")) else "")}
128
+ else:
129
+ info["renderingMode"] = {"value": "unknown", "signal": "no package.json deps or index.html found"}
130
+
131
+ # ── source scan: shells, registration, theming, custom tags ──
132
+ shells, register_files, theme = set(), [], {"themesCss": False, "dataScheme": False, "namedTheme": False}
133
+ scanned = 0
134
+ try:
135
+ for path in _iter_source_files(root):
136
+ scanned += 1
137
+ try:
138
+ with open(path, encoding="utf-8", errors="ignore") as f:
139
+ text = f.read()
140
+ except Exception:
141
+ continue
142
+ for tag in SHELL_TAGS:
143
+ if "<" + tag in text or "adia-" + tag in text:
144
+ shells.add(tag)
145
+ if "@adia-ai/web-components" in text or "@adia-ai/web-modules" in text:
146
+ register_files.append(os.path.relpath(path, root))
147
+ if "themes.css" in text:
148
+ theme["themesCss"] = True
149
+ if "data-scheme" in text:
150
+ theme["dataScheme"] = True
151
+ if re.search(r'\btheme="[a-z]', text):
152
+ theme["namedTheme"] = True
153
+ except Exception as e: # pragma: no cover — never let a scan error kill the probe
154
+ info["probeErrors"].append(f"source scan: {e}")
155
+ info["shellsUsed"] = sorted(shells)
156
+ info["registrationFiles"] = sorted(register_files)[:10]
157
+ info["theme"] = theme
158
+ info["scannedFiles"] = scanned
159
+
160
+ # ── MCP wiring ──
161
+ mcp = _read_json(os.path.join(root, ".mcp.json"))
162
+ servers = (mcp or {}).get("mcpServers") or {}
163
+ a2ui = next((v for k, v in servers.items() if "a2ui" in k), None)
164
+ if a2ui:
165
+ args = " ".join(a2ui.get("args") or [])
166
+ pin = re.search(r"@adia-ai/a2ui-mcp@([\w.\-]+)", args)
167
+ info["a2uiMcp"] = {"configured": True, "pin": pin.group(1) if pin else None}
168
+ else:
169
+ info["a2uiMcp"] = {"configured": False, "pin": None}
170
+
171
+ info["typescript"] = os.path.exists(os.path.join(root, "tsconfig.json"))
172
+ return info
173
+
174
+
175
+ def _selftest():
176
+ import shutil
177
+ import tempfile
178
+
179
+ failures = []
180
+
181
+ def check(name, cond):
182
+ if not cond:
183
+ failures.append(name)
184
+
185
+ # Fixture 1: SPA consumer with adia deps, shells, theme, MCP pin.
186
+ tmp = tempfile.mkdtemp(prefix="adia-info-spa-")
187
+ try:
188
+ with open(os.path.join(tmp, "package.json"), "w") as f:
189
+ json.dump({"dependencies": {"@adia-ai/web-components": "^0.8.0", "vite": "^5.0.0"}}, f)
190
+ with open(os.path.join(tmp, "package-lock.json"), "w") as f:
191
+ f.write("{}")
192
+ with open(os.path.join(tmp, "index.html"), "w") as f:
193
+ f.write('<html data-scheme="dark" theme="ocean"><link href="themes.css">'
194
+ '<script type="module">import "@adia-ai/web-components";</script>'
195
+ "<admin-shell></admin-shell></html>")
196
+ with open(os.path.join(tmp, ".mcp.json"), "w") as f:
197
+ json.dump({"mcpServers": {"a2ui": {"args": ["-y", "@adia-ai/a2ui-mcp@0.8.0"]}}}, f)
198
+ r = probe(tmp)
199
+ check("spa: mode", r["renderingMode"]["value"] == "spa")
200
+ check("spa: adia dep", "@adia-ai/web-components" in r["adiaPackages"])
201
+ check("spa: framework vite", r["framework"] == "vite")
202
+ check("spa: pm npm via lockfile", r["packageManager"] == {"value": "npm", "signal": "package-lock.json"})
203
+ check("spa: shell found", r["shellsUsed"] == ["admin-shell"])
204
+ check("spa: registration file", r["registrationFiles"] == ["index.html"])
205
+ check("spa: theme trio", r["theme"] == {"themesCss": True, "dataScheme": True, "namedTheme": True})
206
+ check("spa: mcp pin", r["a2uiMcp"] == {"configured": True, "pin": "0.8.0"})
207
+ check("spa: not monorepo", r["isFrameworkMonorepo"] is False)
208
+ finally:
209
+ shutil.rmtree(tmp, ignore_errors=True)
210
+
211
+ # Fixture 2: SSR (next) consumer.
212
+ tmp = tempfile.mkdtemp(prefix="adia-info-ssr-")
213
+ try:
214
+ with open(os.path.join(tmp, "package.json"), "w") as f:
215
+ json.dump({"dependencies": {"next": "15.0.0", "@adia-ai/web-components": "^0.8.0"},
216
+ "packageManager": "pnpm@9.0.0"}, f)
217
+ os.makedirs(os.path.join(tmp, "app"))
218
+ r = probe(tmp)
219
+ check("ssr: mode", r["renderingMode"]["value"] == "ssr")
220
+ check("ssr: signal cites next", "next" in r["renderingMode"]["signal"])
221
+ check("ssr: pm field wins", r["packageManager"]["value"] == "pnpm")
222
+ check("ssr: mcp unconfigured", r["a2uiMcp"]["configured"] is False)
223
+ finally:
224
+ shutil.rmtree(tmp, ignore_errors=True)
225
+
226
+ # Fixture 3: empty dir never crashes, mode unknown.
227
+ tmp = tempfile.mkdtemp(prefix="adia-info-empty-")
228
+ try:
229
+ r = probe(tmp)
230
+ check("empty: mode unknown", r["renderingMode"]["value"] == "unknown")
231
+ check("empty: no errors", r["probeErrors"] == [])
232
+ json.dumps(r) # must be serializable
233
+ finally:
234
+ shutil.rmtree(tmp, ignore_errors=True)
235
+
236
+ # Fixture 4 (gh#1122, REQ-06): -h/--help must print usage and exit 0 —
237
+ # NEVER silently probe a literal "-h" directory and return a degenerate
238
+ # JSON payload with exit 0 (the actual repro this fixture locks shut).
239
+ import contextlib
240
+ import io
241
+ for flag in ("-h", "--help"):
242
+ buf = io.StringIO()
243
+ with contextlib.redirect_stdout(buf):
244
+ rc = main(["adia-info", flag])
245
+ out = buf.getvalue()
246
+ check(f"help: {flag} exits 0", rc == 0)
247
+ check(f"help: {flag} prints usage", out.startswith("usage:"))
248
+ check(f"help: {flag} is not a JSON probe payload", "probeRoot" not in out)
249
+
250
+ if failures:
251
+ print("selftest FAIL: " + ", ".join(failures), file=sys.stderr)
252
+ return 1
253
+ print("selftest OK — 4 fixtures")
254
+ return 0
255
+
256
+
257
+ def _probe_staleness(info):
258
+ """Opt-in (--staleness): compare installed web-components against npm latest.
259
+
260
+ Network + subprocess, so NEVER part of the default probe — the default
261
+ output feeds skill-load context injection and must stay offline-fast.
262
+ Fail-soft like every other field."""
263
+ import subprocess
264
+ try:
265
+ out = subprocess.run(
266
+ ["npm", "view", "@adia-ai/web-components", "version"],
267
+ capture_output=True, text=True, timeout=10,
268
+ )
269
+ if out.returncode != 0:
270
+ raise RuntimeError((out.stderr or out.stdout or "npm view exited nonzero").strip())
271
+ latest = out.stdout.strip() or None
272
+ info["npmLatest"] = latest
273
+ installed = info.get("installedVersion")
274
+ info["staleInstall"] = (
275
+ {"value": installed != latest, "installed": installed, "latest": latest,
276
+ "signal": "npm view @adia-ai/web-components version"}
277
+ if latest and installed else None
278
+ )
279
+ except Exception as e:
280
+ info["npmLatest"] = None
281
+ info["staleInstall"] = None
282
+ info["probeErrors"].append(f"staleness: {e}")
283
+
284
+
285
+ def main(argv):
286
+ rest = argv[1:]
287
+ # REQ-06 (gh#1122): -h/--help must print usage and exit 0 BEFORE any
288
+ # positional/root resolution — the bug was that only "--"-prefixed
289
+ # tokens were filtered into `flags`, so single-dash "-h" fell through
290
+ # into `args` and became `root`, silently probing a literal directory
291
+ # named "-h". Checked first, in any position, so it can never fall
292
+ # through regardless of what else is on the command line.
293
+ if any(a in ("-h", "--help") for a in rest):
294
+ print("usage: adia-info [dir] [--staleness] probe an adia-ui consumer app (default: cwd)")
295
+ return 0
296
+ if rest and rest[0] == "selftest":
297
+ return _selftest()
298
+ args = [a for a in rest if not a.startswith("-")]
299
+ flags = {a for a in rest if a.startswith("-")}
300
+ root = args[0] if args else "."
301
+ try:
302
+ info = probe(root)
303
+ if "--staleness" in flags:
304
+ _probe_staleness(info)
305
+ print(json.dumps(info, indent=1))
306
+ except Exception as e: # ultimate fail-soft: emit the error as JSON, exit 0
307
+ print(json.dumps({"probeErrors": [str(e)]}))
308
+ return 0
309
+
310
+
311
+ if __name__ == "__main__":
312
+ sys.exit(main(sys.argv))