@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.
- package/CHANGELOG.md +44 -27
- package/README.md +94 -29
- package/TOOLS.md +178 -16
- package/bin/adia-mcp +17 -8
- package/factory/public-surface.json +20 -0
- package/factory/resources/data-wiring.md +108 -0
- package/factory/resources/pattern-index.md +786 -0
- package/factory/resources/shell-selection.md +86 -0
- package/factory/resources/token-pairing-laws.md +68 -0
- package/factory/server.d.ts +12 -0
- package/factory/server.js +72 -0
- package/factory/tools/factory.js +277 -0
- package/factory/vendor/MANIFEST.json +29 -0
- package/factory/vendor/adia-contract-check.mjs +432 -0
- package/factory/vendor/adia-info +312 -0
- package/factory/vendor/adia-lint +396 -0
- package/factory/vendor/adia-probe.mjs +413 -0
- package/factory/vendor/adia-scaffold +801 -0
- package/factory/vendor/record-lint +278 -0
- package/gen-ui/server.d.ts +1 -1
- package/gen-ui/tools/corpus.js +1 -1
- package/gen-ui/tools/discovery.js +82 -0
- package/gen-ui/tools/feedback.js +1 -1
- package/package.json +13 -3
- package/protocol/server.d.ts +3 -2
- package/protocol/server.js +1 -1
- package/protocol/tools/protocol.js +123 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""adia-scaffold — lay the minimal bones of an adia-ui app (structure, not opinions).
|
|
3
|
+
|
|
4
|
+
It scaffolds the load-bearing skeleton and stops — exact package paths, versions, and the first
|
|
5
|
+
real screen are yours (and the a2ui MCP's), because those are what drift. Modes:
|
|
6
|
+
|
|
7
|
+
adia-scaffold spa <name> [-o DIR] [--force]
|
|
8
|
+
A client-rendered app: the four-axis layout (spec/ plan/ app/ skills/), a static host
|
|
9
|
+
document (cascade-ordered links + one registration script), and a self-booting placeholder
|
|
10
|
+
surface.
|
|
11
|
+
|
|
12
|
+
adia-scaffold ssr <name> --framework {next,nuxt,sveltekit,astro} [-o DIR] [--force]
|
|
13
|
+
The adia integration layer to drop into an EXISTING framework app: a client-boundary provider
|
|
14
|
+
(deferred registration) for the chosen framework + a README integration checklist.
|
|
15
|
+
|
|
16
|
+
adia-scaffold page <name> [-o DIR] [--duo] [--force]
|
|
17
|
+
Add a page to a surface: a page-trio (<name>.html + .contents.html + .contents.js exporting
|
|
18
|
+
setup) — or a page-DUO (no .contents.js) with --duo, for a purely declarative page.
|
|
19
|
+
|
|
20
|
+
adia-scaffold component <tag> [-o DIR] [--force]
|
|
21
|
+
Add a light-DOM component folder: components/<tag>/<tag>.{js,css} (a lint-clean skeleton —
|
|
22
|
+
self-booting container, two-block @scope, token-only).
|
|
23
|
+
|
|
24
|
+
adia-scaffold selftest
|
|
25
|
+
Scaffold each shape to a temp dir and assert the expected files exist. Exit 1 on failure.
|
|
26
|
+
|
|
27
|
+
Refuses to overwrite existing files unless --force. Stdlib only (Python 3.8+).
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tarfile
|
|
36
|
+
import tempfile
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _slug(name):
|
|
40
|
+
s = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
|
41
|
+
return s or "app"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tag(name):
|
|
45
|
+
"""A valid custom-element tag (must contain a hyphen)."""
|
|
46
|
+
s = _slug(name)
|
|
47
|
+
return s if "-" in s else f"{s}-app"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cls(tag):
|
|
51
|
+
return "UI" + "".join(p.capitalize() for p in tag.split("-"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---- SPA templates ---------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _spa_files(name):
|
|
57
|
+
tag, title = _tag(name), name.strip() or _slug(name)
|
|
58
|
+
cls = _cls(tag)
|
|
59
|
+
return {
|
|
60
|
+
"spec/BRIEF.md": f"# {title} — brief\n\nWhat this app is, who it's for, the one job it does.\n",
|
|
61
|
+
"plan/ROADMAP.md": f"# {title} — roadmap\n\n- [ ] First surface\n",
|
|
62
|
+
"skills/.gitkeep": "# app-specific expert skill goes here (optional)\n",
|
|
63
|
+
"app/shared/.gitkeep": "# cross-surface source: DataClient, loaders, mappers, images\n",
|
|
64
|
+
f"app/{tag}/src/index.html": _SPA_HTML.format(tag=tag, title=title),
|
|
65
|
+
f"app/{tag}/src/index.css": _SPA_PAGE_CSS.format(tag=tag),
|
|
66
|
+
f"app/{tag}/src/components/{tag}/{tag}.js": _SPA_JS.format(tag=tag, cls=cls, title=title),
|
|
67
|
+
f"app/{tag}/src/components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
|
|
68
|
+
f"app/{tag}/vite.config.js": _VITE_CONFIG.format(tag=tag),
|
|
69
|
+
f"app/{tag}/package.json": _PKG_JSON.format(tag=tag, title=title),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SPA_HTML = """<!doctype html>
|
|
74
|
+
<html lang="en" data-theme="auto">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="utf-8" />
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
78
|
+
<title>{title}</title>
|
|
79
|
+
|
|
80
|
+
<!--
|
|
81
|
+
HOW TO SERVE @adia-ai/web-components — pick one:
|
|
82
|
+
|
|
83
|
+
A) Vite (recommended, npm consumer): run `npm install` then `vite` from app/{tag}/.
|
|
84
|
+
Vite resolves @adia-ai/* bare specifiers — CSS *and* JS — through its module graph
|
|
85
|
+
automatically; the importmap below is NOT needed in this mode. That's why the
|
|
86
|
+
foundation + barrel CSS below is imported from the registration script
|
|
87
|
+
(./components/{tag}/{tag}.js), never linked as a raw `/node_modules/...` URL — this
|
|
88
|
+
file's own vite.config.js sets `root: 'src'`, so a *static* URL at that literal path
|
|
89
|
+
resolves against src/ (where node_modules doesn't exist) and 404s — silently, behind
|
|
90
|
+
Vite's SPA-fallback middleware, which serves this index.html back with a 200.
|
|
91
|
+
|
|
92
|
+
B) Import-map (no bundler, e.g. native browser modules or a CDN):
|
|
93
|
+
Uncomment the importmap block below and point the paths to wherever the
|
|
94
|
+
@adia-ai packages are served — a local static server, esm.sh, or unpkg. There's no
|
|
95
|
+
module graph to carry a CSS import without a bundler, so also uncomment the matching
|
|
96
|
+
<link> pair below and point it at the same location.
|
|
97
|
+
|
|
98
|
+
C) Monorepo / dev server (framework contributors only):
|
|
99
|
+
The /packages/web-components/... paths resolve when the monorepo's own vite dev
|
|
100
|
+
server is running (its root serves the whole repo). This is NOT a consumer
|
|
101
|
+
deployment mode.
|
|
102
|
+
|
|
103
|
+
Cascade order is load-bearing (later wins):
|
|
104
|
+
foundation (host.css) → barrel components (index.css) → page framing → surface chrome
|
|
105
|
+
— the first two arrive via the registration script's CSS import, in that order.
|
|
106
|
+
-->
|
|
107
|
+
|
|
108
|
+
<!--
|
|
109
|
+
OPTION B — import-map (uncomment + adjust URLs when not using Vite):
|
|
110
|
+
<script type="importmap">
|
|
111
|
+
{{
|
|
112
|
+
"imports": {{
|
|
113
|
+
"@adia-ai/web-components": "https://esm.sh/@adia-ai/web-components",
|
|
114
|
+
"@adia-ai/web-components/": "https://esm.sh/@adia-ai/web-components/"
|
|
115
|
+
}}
|
|
116
|
+
}}
|
|
117
|
+
</script>
|
|
118
|
+
<link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/styles/host.css" />
|
|
119
|
+
<link rel="stylesheet" href="https://esm.sh/@adia-ai/web-components/styles/index.css" />
|
|
120
|
+
-->
|
|
121
|
+
|
|
122
|
+
<link rel="stylesheet" href="./index.css" /> <!-- page framing -->
|
|
123
|
+
<link rel="stylesheet" href="./components/{tag}/{tag}.css" /> <!-- the surface's chrome -->
|
|
124
|
+
|
|
125
|
+
<!-- One registration script: imports the foundation + barrel CSS (cascade order preserved
|
|
126
|
+
by import order), then registers every primitive (including router-ui). Bare @adia-ai/*
|
|
127
|
+
specifiers resolve via Vite's module graph (node_modules) or the importmap above —
|
|
128
|
+
never a static /node_modules/... URL. -->
|
|
129
|
+
<script type="module" src="./components/{tag}/{tag}.js"></script>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<{tag}></{tag}>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_VITE_CONFIG = """import {{ defineConfig }} from 'vite';
|
|
138
|
+
import {{ resolve }} from 'node:path';
|
|
139
|
+
|
|
140
|
+
// Consumer vite config for {tag}.
|
|
141
|
+
// Run: npm install && npx vite
|
|
142
|
+
//
|
|
143
|
+
// root: 'src' makes src/index.html the dev-server entry, and its relative CSS/JS
|
|
144
|
+
// paths resolve against src/ — but a *static* URL like `/node_modules/...` in an
|
|
145
|
+
// <link>/<script src> would resolve against src/ too (node_modules lives outside
|
|
146
|
+
// it) and 404 silently, behind Vite's SPA fallback. @adia-ai/* CSS and JS bare
|
|
147
|
+
// specifiers avoid this: Vite's MODULE resolver (not its static file server)
|
|
148
|
+
// walks node_modules the normal Node way — see src/index.html's own comment.
|
|
149
|
+
export default defineConfig({{
|
|
150
|
+
root: 'src',
|
|
151
|
+
server: {{
|
|
152
|
+
open: true,
|
|
153
|
+
}},
|
|
154
|
+
build: {{
|
|
155
|
+
outDir: '../dist',
|
|
156
|
+
emptyOutDir: true,
|
|
157
|
+
}},
|
|
158
|
+
}});
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
_PKG_JSON = """{{
|
|
162
|
+
"name": "{tag}",
|
|
163
|
+
"version": "0.1.0",
|
|
164
|
+
"description": "{title}",
|
|
165
|
+
"private": true,
|
|
166
|
+
"type": "module",
|
|
167
|
+
"scripts": {{
|
|
168
|
+
"dev": "vite",
|
|
169
|
+
"build": "vite build",
|
|
170
|
+
"preview": "vite preview"
|
|
171
|
+
}},
|
|
172
|
+
"dependencies": {{
|
|
173
|
+
"@adia-ai/web-components": "latest"
|
|
174
|
+
}},
|
|
175
|
+
"devDependencies": {{
|
|
176
|
+
"vite": "^5.0.0"
|
|
177
|
+
}}
|
|
178
|
+
}}
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
_SPA_PAGE_CSS = """/* Page framing — size + center the surface. Tokens only; never re-roll :where(html,body). */
|
|
182
|
+
{tag} {{
|
|
183
|
+
display: block;
|
|
184
|
+
max-inline-size: 80rem;
|
|
185
|
+
margin-inline: auto;
|
|
186
|
+
}}
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
_SPA_JS = """import '@adia-ai/web-components'; // registration barrel — defines every *-ui tag (JS side-effect; gh#1257)
|
|
190
|
+
import '@adia-ai/web-components/css'; // foundation + barrel CSS — keep BOTH: this import carries no JS
|
|
191
|
+
import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
|
|
192
|
+
import {{ UIElement }} from '@adia-ai/web-components/core/element';
|
|
193
|
+
|
|
194
|
+
class {cls} extends UIElement {{
|
|
195
|
+
#booted = false;
|
|
196
|
+
connected() {{
|
|
197
|
+
if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
|
|
198
|
+
this.#booted = true;
|
|
199
|
+
// a11y baked in (gh#1252): region landmark on page-ui + a SEMANTIC heading —
|
|
200
|
+
// text-ui variants are presentational-only (text.yaml), so the heading is an
|
|
201
|
+
// authored slot child carrying role="heading" aria-level="1" explicitly.
|
|
202
|
+
this.innerHTML = `
|
|
203
|
+
<page-ui role="region" aria-label="{title}">
|
|
204
|
+
<header-ui>
|
|
205
|
+
<text-ui slot="heading" variant="title" role="heading" aria-level="1">{title}</text-ui>
|
|
206
|
+
</header-ui>
|
|
207
|
+
<section-ui>
|
|
208
|
+
<text-ui>Scaffolded by adia-ui-factory. Build the real surface with /screen-composition.</text-ui>
|
|
209
|
+
</section-ui>
|
|
210
|
+
</page-ui>`;
|
|
211
|
+
}}
|
|
212
|
+
}}
|
|
213
|
+
defineIfFree('{tag}', {cls});
|
|
214
|
+
export {{ {cls} }};
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
_SPA_CSS = """@scope ({tag}) {{
|
|
218
|
+
:where(:scope) {{ /* zero-specificity tokens — themes + consumers override cleanly */
|
|
219
|
+
--{tag}-gap: var(--a-space-4);
|
|
220
|
+
}}
|
|
221
|
+
:scope {{ /* base — size-agnostic: the CONSUMER owns width/height */
|
|
222
|
+
display: block;
|
|
223
|
+
padding: var(--a-space-4);
|
|
224
|
+
}}
|
|
225
|
+
}}
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---- SSR templates ---------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
_SSR = {
|
|
232
|
+
"next": {
|
|
233
|
+
"path": "app/providers/adia-provider.tsx",
|
|
234
|
+
"hook": "useEffect",
|
|
235
|
+
"link": "<Link> from next/link + app/**/page.tsx",
|
|
236
|
+
"bind": "a ref + useEffect to set non-string props",
|
|
237
|
+
"body": """'use client';
|
|
238
|
+
import { useEffect } from 'react';
|
|
239
|
+
|
|
240
|
+
// Client-boundary registration. A top-level `import '@adia-ai/web-components'` throws
|
|
241
|
+
// `HTMLElement is not defined` during SSR — defer it into useEffect. Mount <AdiaProvider> high.
|
|
242
|
+
export function AdiaProvider({ children }: { children: React.ReactNode }) {
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
import('@adia-ai/web-components')
|
|
245
|
+
.then(() => import('@adia-ai/web-modules/shell'))
|
|
246
|
+
.then(() => import('@adia-ai/web-components/css'));
|
|
247
|
+
}, []);
|
|
248
|
+
return <>{children}</>;
|
|
249
|
+
}
|
|
250
|
+
""",
|
|
251
|
+
},
|
|
252
|
+
"nuxt": {
|
|
253
|
+
"path": "components/AdiaKit.client.vue",
|
|
254
|
+
"hook": "onMounted",
|
|
255
|
+
"link": "<NuxtLink> + pages/**.vue",
|
|
256
|
+
"bind": ":prop= (property binding)",
|
|
257
|
+
"body": """<script setup lang=\"ts\">
|
|
258
|
+
import { onMounted } from 'vue';
|
|
259
|
+
|
|
260
|
+
// .client.vue is Nuxt's SSR boundary; register on the client only.
|
|
261
|
+
onMounted(async () => {
|
|
262
|
+
await import('@adia-ai/web-components');
|
|
263
|
+
await import('@adia-ai/web-modules/shell');
|
|
264
|
+
await import('@adia-ai/web-components/css');
|
|
265
|
+
});
|
|
266
|
+
</script>
|
|
267
|
+
|
|
268
|
+
<template>
|
|
269
|
+
<slot />
|
|
270
|
+
</template>
|
|
271
|
+
""",
|
|
272
|
+
},
|
|
273
|
+
"sveltekit": {
|
|
274
|
+
"path": "src/lib/AdiaKit.svelte",
|
|
275
|
+
"hook": "onMount",
|
|
276
|
+
"link": "<a href> + src/routes/**/+page.svelte",
|
|
277
|
+
"bind": "bind:value (two-way)",
|
|
278
|
+
"body": """<script>
|
|
279
|
+
import { onMount } from 'svelte';
|
|
280
|
+
|
|
281
|
+
// onMount is SvelteKit's hydration barrier — register on the client only.
|
|
282
|
+
onMount(async () => {
|
|
283
|
+
await import('@adia-ai/web-components');
|
|
284
|
+
await import('@adia-ai/web-modules/shell');
|
|
285
|
+
await import('@adia-ai/web-components/css');
|
|
286
|
+
});
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<slot />
|
|
290
|
+
""",
|
|
291
|
+
},
|
|
292
|
+
"astro": {
|
|
293
|
+
"path": "src/components/AdiaKit.astro",
|
|
294
|
+
"hook": "a client <script>",
|
|
295
|
+
"link": "<a href> (or <ViewTransitions/>)",
|
|
296
|
+
"bind": "drop to a framework island for reactive props",
|
|
297
|
+
"body": """---
|
|
298
|
+
// CSS is server-safe (no browser APIs); JS registration runs in the browser <script>.
|
|
299
|
+
import '@adia-ai/web-components/css';
|
|
300
|
+
---
|
|
301
|
+
<slot />
|
|
302
|
+
<script>
|
|
303
|
+
import '@adia-ai/web-components';
|
|
304
|
+
import '@adia-ai/web-modules/shell';
|
|
305
|
+
</script>
|
|
306
|
+
""",
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _ssr_files(name, framework):
|
|
312
|
+
spec = _SSR[framework]
|
|
313
|
+
title = name.strip() or _slug(name)
|
|
314
|
+
readme = _SSR_README.format(
|
|
315
|
+
title=title, framework=framework, path=spec["path"],
|
|
316
|
+
hook=spec["hook"], link=spec["link"], bind=spec["bind"])
|
|
317
|
+
return {spec["path"]: spec["body"], "README.md": readme}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_SSR_README = """# {title} — adia-ui integration ({framework})
|
|
321
|
+
|
|
322
|
+
Drop `{path}` into your {framework} app and mount it high (around the layout/root).
|
|
323
|
+
|
|
324
|
+
Integration checklist (the `host-wiring` skill + its ssr-integration reference own the depth):
|
|
325
|
+
|
|
326
|
+
1. **Registration is client-only.** This provider defers the kit import into {hook}; never import
|
|
327
|
+
`@adia-ai/web-components` at server module top-level (it throws `HTMLElement is not defined`).
|
|
328
|
+
2. **Routing stays the framework's.** Do NOT mount `<router-ui>` — exactly one route owner. Use {link}.
|
|
329
|
+
3. **Data:** fetch on the server, pass as initial props, refresh on the client.
|
|
330
|
+
4. **State:** cross-cutting state (sidebar, nav, optimistic UI) goes in cookies/session — the shell
|
|
331
|
+
re-mounts per navigation, so component-lifetime signals are lost.
|
|
332
|
+
5. **Props:** set non-string props as properties ({bind}), not stringified attributes.
|
|
333
|
+
6. **CSS:** import the kit CSS once (server-safe).
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---- page & component templates --------------------------------------------
|
|
338
|
+
|
|
339
|
+
_PAGE_HTML = """<!doctype html>
|
|
340
|
+
<html lang="en" data-theme="auto">
|
|
341
|
+
<head>
|
|
342
|
+
<meta charset="utf-8" />
|
|
343
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
344
|
+
<title>{title}</title>
|
|
345
|
+
<!-- Foundation + barrel CSS, and registration: bare @adia-ai/* specifiers resolve
|
|
346
|
+
through Vite's module graph (or an importmap without a bundler) — never a static
|
|
347
|
+
/node_modules/... URL, which 404s silently under Vite's own SPA fallback (see
|
|
348
|
+
spa-architecture.md for the importmap / CDN options). -->
|
|
349
|
+
<script type="module">
|
|
350
|
+
import '@adia-ai/web-components';
|
|
351
|
+
import '@adia-ai/web-components/css';
|
|
352
|
+
</script>
|
|
353
|
+
</head>
|
|
354
|
+
<body>
|
|
355
|
+
<main id="page-root"><p>Loading…</p></main>
|
|
356
|
+
<script type="module">
|
|
357
|
+
// page-trio loader: fetch the fragment, inject it, then run setup() if a .contents.js exists.
|
|
358
|
+
const root = document.getElementById('page-root');
|
|
359
|
+
root.innerHTML = await (await fetch('./{slug}.contents.html')).text();
|
|
360
|
+
{js_import} </script>
|
|
361
|
+
</body>
|
|
362
|
+
</html>
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
_PAGE_CONTENTS = """<col-ui gap="4">
|
|
366
|
+
<text-ui variant="heading">{title}</text-ui>
|
|
367
|
+
<text-ui>Page scaffold. Compose the real content with /screen-composition.</text-ui>
|
|
368
|
+
</col-ui>
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
_PAGE_JS = """export default function setup(root) {
|
|
372
|
+
// Wire behavior here: events, property-API (e.g. el.columns = [...]), data fetch, streaming.
|
|
373
|
+
// Register any custom components this page uses via a side-effect import.
|
|
374
|
+
}
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
_COMPONENT_JS = """import {{ defineIfFree }} from '@adia-ai/web-components/core/register';
|
|
378
|
+
import {{ UIElement }} from '@adia-ai/web-components/core/element';
|
|
379
|
+
|
|
380
|
+
class {cls} extends UIElement {{
|
|
381
|
+
#booted = false;
|
|
382
|
+
connected() {{
|
|
383
|
+
if (this.#booted) return; // the callback re-fires whenever the element moves in the DOM
|
|
384
|
+
this.#booted = true;
|
|
385
|
+
this.innerHTML = `<col-ui gap="2"><text-ui>{tag}</text-ui></col-ui>`;
|
|
386
|
+
}}
|
|
387
|
+
}}
|
|
388
|
+
defineIfFree('{tag}', {cls});
|
|
389
|
+
export {{ {cls} }};
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _page_files(name, duo):
|
|
394
|
+
slug, title = _slug(name), (name.strip() or _slug(name))
|
|
395
|
+
js_import = "" if duo else f" (await import('./{slug}.contents.js')).default?.(root);\n"
|
|
396
|
+
files = {
|
|
397
|
+
f"{slug}.html": _PAGE_HTML.format(slug=slug, title=title, js_import=js_import),
|
|
398
|
+
f"{slug}.contents.html": _PAGE_CONTENTS.format(title=title),
|
|
399
|
+
}
|
|
400
|
+
if not duo:
|
|
401
|
+
files[f"{slug}.contents.js"] = _PAGE_JS
|
|
402
|
+
return files, slug
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _component_files(name):
|
|
406
|
+
tag = _tag(name)
|
|
407
|
+
cls = _cls(tag)
|
|
408
|
+
return {
|
|
409
|
+
f"components/{tag}/{tag}.js": _COMPONENT_JS.format(tag=tag, cls=cls),
|
|
410
|
+
f"components/{tag}/{tag}.css": _SPA_CSS.format(tag=tag),
|
|
411
|
+
}, tag
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---- writer ----------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def _write(root, files, force):
|
|
417
|
+
written, skipped = [], []
|
|
418
|
+
for rel, content in files.items():
|
|
419
|
+
dest = os.path.join(root, rel)
|
|
420
|
+
if os.path.exists(dest) and not force:
|
|
421
|
+
skipped.append(rel)
|
|
422
|
+
continue
|
|
423
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
424
|
+
with open(dest, "w", encoding="utf-8") as f:
|
|
425
|
+
f.write(content)
|
|
426
|
+
written.append(rel)
|
|
427
|
+
return written, skipped
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _scaffold(mode, name, framework, outdir, force):
|
|
431
|
+
files = _spa_files(name) if mode == "spa" else _ssr_files(name, framework)
|
|
432
|
+
root = os.path.join(outdir, _slug(name))
|
|
433
|
+
written, skipped = _write(root, files, force)
|
|
434
|
+
return root, written, skipped
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _inventory(app_root):
|
|
438
|
+
"""Score an app dir against project-shapes.md's structure rubric.
|
|
439
|
+
|
|
440
|
+
Emits the inventory scorecard (gate · pass/fail · cited path) — the
|
|
441
|
+
mechanizable 4 of the rubric's 5 gates. Shape-match ('the layout
|
|
442
|
+
matches one of the three shapes') and duplicated-cross-surface-code
|
|
443
|
+
stay the model's judgment; the scorecard prints them as JUDGMENT rows
|
|
444
|
+
so no report silently omits them. Factory-audit Wave 2 (gh#259):
|
|
445
|
+
the rubric was the skill's done-gate with nothing scoring it.
|
|
446
|
+
"""
|
|
447
|
+
rows = [] # (gate, status, evidence)
|
|
448
|
+
|
|
449
|
+
# Gate 1 — four-axis present (spec/ + plan/ + app/; skills/ optional)
|
|
450
|
+
missing = [d for d in ("spec", "plan", "app") if not os.path.isdir(os.path.join(app_root, d))]
|
|
451
|
+
rows.append(("four-axis present", "PASS" if not missing else "FAIL",
|
|
452
|
+
"spec/ plan/ app/ all present" if not missing else f"missing: {', '.join(missing)}/"))
|
|
453
|
+
|
|
454
|
+
# Gates 3+4 — walk surfaces under app/
|
|
455
|
+
duo_bad, trio_bad, loose_components = [], [], []
|
|
456
|
+
app_dir = os.path.join(app_root, "app")
|
|
457
|
+
for dirpath, dirnames, filenames in os.walk(app_dir):
|
|
458
|
+
dirnames[:] = [d for d in dirnames if d not in ("node_modules", "dist", ".git")]
|
|
459
|
+
for fn in filenames:
|
|
460
|
+
path = os.path.join(dirpath, fn)
|
|
461
|
+
rel = os.path.relpath(path, app_root)
|
|
462
|
+
if fn.endswith(".contents.js"):
|
|
463
|
+
# trio member: must export setup
|
|
464
|
+
try:
|
|
465
|
+
src = open(path, encoding="utf-8", errors="ignore").read()
|
|
466
|
+
except OSError:
|
|
467
|
+
src = ""
|
|
468
|
+
if "export" not in src or "setup" not in src:
|
|
469
|
+
trio_bad.append(rel + " (no exported setup)")
|
|
470
|
+
html = path[: -len(".contents.js")] + ".html"
|
|
471
|
+
if not os.path.exists(html):
|
|
472
|
+
duo_bad.append(rel + " (orphan .contents.js — dead-DUO smell)")
|
|
473
|
+
if fn.endswith(".js") and not fn.endswith((".contents.js", ".config.js", ".test.js")):
|
|
474
|
+
parent = os.path.basename(dirpath)
|
|
475
|
+
grandparent = os.path.basename(os.path.dirname(dirpath))
|
|
476
|
+
base = fn[:-3]
|
|
477
|
+
# component form: components/<tag>/<tag>.js
|
|
478
|
+
if grandparent == "components" and parent != base:
|
|
479
|
+
loose_components.append(rel + f" (dir '{parent}' ≠ tag '{base}')")
|
|
480
|
+
elif parent == "components":
|
|
481
|
+
loose_components.append(rel + " (bare file directly under components/)")
|
|
482
|
+
rows.append(("page form correct", "PASS" if not (duo_bad or trio_bad) else "FAIL",
|
|
483
|
+
"; ".join(duo_bad + trio_bad) or "every .contents.js exports setup, none orphaned"))
|
|
484
|
+
rows.append(("components foldered", "PASS" if not loose_components else "FAIL",
|
|
485
|
+
"; ".join(loose_components) or "every component is components/<tag>/<tag>.js"))
|
|
486
|
+
|
|
487
|
+
rows.append(("shape declared & matched", "JUDGMENT",
|
|
488
|
+
"compare the tree against project-shapes.md's three shape trees — not scriptable"))
|
|
489
|
+
rows.append(("no duplicated cross-surface code", "JUDGMENT",
|
|
490
|
+
"review app/shared/ vs per-surface copies — not scriptable"))
|
|
491
|
+
|
|
492
|
+
width = max(len(r[0]) for r in rows)
|
|
493
|
+
print(f"[inventory] {app_root}")
|
|
494
|
+
fails = 0
|
|
495
|
+
for gate, status, evidence in rows:
|
|
496
|
+
mark = {"PASS": "✓", "FAIL": "✗", "JUDGMENT": "◆"}[status]
|
|
497
|
+
if status == "FAIL":
|
|
498
|
+
fails += 1
|
|
499
|
+
print(f" {mark} {gate.ljust(width)} {status:<8} {evidence}")
|
|
500
|
+
print(f"[inventory] {fails} mechanized gate(s) failing; 2 judgment gates remain the model's."
|
|
501
|
+
if fails else "[inventory] mechanized gates clean; 2 judgment gates remain the model's.")
|
|
502
|
+
return 1 if fails else 0
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---- REQ-04: template↔exports-map drift gate (packed-tarball resolution) --
|
|
506
|
+
#
|
|
507
|
+
# Resolves every @adia-ai/web-components / @adia-ai/web-modules bare
|
|
508
|
+
# specifier the templates emit against the PACKED, extracted artifact —
|
|
509
|
+
# not the repo tree (npm pack's `files` filtering can diverge from it) and
|
|
510
|
+
# not the public registry (gh#1120's acceptance needs the same-cut
|
|
511
|
+
# artifact; lockstep releases make same-cut resolution the binding check).
|
|
512
|
+
# `scripts/verify/exports-wildcard-resolution.mjs` (gh#296) is the existing
|
|
513
|
+
# precedent for this exact style of live-resolution check; this is its
|
|
514
|
+
# manual-walk equivalent in Python, scoped to what the scaffold emits.
|
|
515
|
+
|
|
516
|
+
_SPECIFIER_RE = re.compile(
|
|
517
|
+
r"""(?:\bimport\s*\(\s*|\bimport\s+|\bfrom\s+)['"](@adia-ai/(?:web-components|web-modules)(?:/[^'"]*)?)['"]"""
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _extract_specifiers(text):
|
|
522
|
+
"""Every @adia-ai/web-components|web-modules bare-specifier import in a
|
|
523
|
+
generated file's source text (static import, side-effect import, or
|
|
524
|
+
dynamic import() — the three forms the ssr templates use)."""
|
|
525
|
+
return {m.group(1) for m in _SPECIFIER_RE.finditer(text)}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _emitted_specifiers_by_mode():
|
|
529
|
+
"""Every specifier each scaffold mode's templates emit, keyed by a
|
|
530
|
+
label for the FAIL line — spa, all four ssr frameworks, page, and
|
|
531
|
+
component (REQ-04's named blast radius)."""
|
|
532
|
+
by_mode = {}
|
|
533
|
+
by_mode["spa"] = set().union(*[_extract_specifiers(c) for c in _spa_files("Demo App").values()])
|
|
534
|
+
for fw in sorted(_SSR):
|
|
535
|
+
files = _ssr_files(f"demo-{fw}", fw)
|
|
536
|
+
by_mode[f"ssr/{fw}"] = set().union(*[_extract_specifiers(c) for c in files.values()])
|
|
537
|
+
page_files, _ = _page_files("Live View", False)
|
|
538
|
+
by_mode["page"] = set().union(*[_extract_specifiers(c) for c in page_files.values()]) if page_files else set()
|
|
539
|
+
component_files, _ = _component_files("data-badge")
|
|
540
|
+
by_mode["component"] = set().union(*[_extract_specifiers(c) for c in component_files.values()])
|
|
541
|
+
return by_mode
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _split_specifier(spec):
|
|
545
|
+
"""'@adia-ai/web-components/core/register' -> ('@adia-ai/web-components', 'core/register').
|
|
546
|
+
Bare '@adia-ai/web-components' -> (pkg, '')."""
|
|
547
|
+
for pkg in ("@adia-ai/web-components", "@adia-ai/web-modules"):
|
|
548
|
+
if spec == pkg:
|
|
549
|
+
return pkg, ""
|
|
550
|
+
if spec.startswith(pkg + "/"):
|
|
551
|
+
return pkg, spec[len(pkg) + 1:]
|
|
552
|
+
return None, None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _pick_export_target(target):
|
|
556
|
+
"""Pick the 'import' condition (falling back to 'default') from an
|
|
557
|
+
exports-map value — either a bare string or a conditions dict."""
|
|
558
|
+
if isinstance(target, str):
|
|
559
|
+
return target
|
|
560
|
+
if isinstance(target, dict):
|
|
561
|
+
for cond in ("import", "default"):
|
|
562
|
+
v = target.get(cond)
|
|
563
|
+
if isinstance(v, str):
|
|
564
|
+
return v
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _resolve_export(pkg_json, subpath):
|
|
569
|
+
"""Manual walk of pkg_json's exports map (exact key, else the single-*
|
|
570
|
+
wildcard key with the longest matching prefix) + wildcard substitution.
|
|
571
|
+
Returns the resolved relative path (still '*'-free), or None if no key
|
|
572
|
+
in the map matches this subpath at all."""
|
|
573
|
+
exports = pkg_json.get("exports")
|
|
574
|
+
if not isinstance(exports, dict):
|
|
575
|
+
return None
|
|
576
|
+
key = "." if subpath == "" else "./" + subpath
|
|
577
|
+
if key in exports:
|
|
578
|
+
return _pick_export_target(exports[key])
|
|
579
|
+
best = None
|
|
580
|
+
for k, v in exports.items():
|
|
581
|
+
if k.count("*") != 1:
|
|
582
|
+
continue
|
|
583
|
+
prefix, _, suffix = k.partition("*")
|
|
584
|
+
if key.startswith(prefix) and key.endswith(suffix) and len(key) >= len(prefix) + len(suffix):
|
|
585
|
+
if best is None or len(prefix) > len(best[0]):
|
|
586
|
+
captured = key[len(prefix):len(key) - len(suffix)] if suffix else key[len(prefix):]
|
|
587
|
+
best = (prefix, captured, v)
|
|
588
|
+
if best is None:
|
|
589
|
+
return None
|
|
590
|
+
_, captured, target = best
|
|
591
|
+
rel = _pick_export_target(target)
|
|
592
|
+
return rel.replace("*", captured) if rel else None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _pack_and_extract(pkg_dir, tmp):
|
|
596
|
+
"""npm pack pkg_dir for real (no network — local tarballing of the
|
|
597
|
+
`files` allowlist) and extract the tarball, so resolution runs against
|
|
598
|
+
what actually SHIPS, not the repo tree."""
|
|
599
|
+
out = subprocess.run(
|
|
600
|
+
["npm", "pack", "--silent", "--pack-destination", tmp],
|
|
601
|
+
cwd=pkg_dir, capture_output=True, text=True,
|
|
602
|
+
)
|
|
603
|
+
if out.returncode != 0:
|
|
604
|
+
raise RuntimeError(f"npm pack failed in {pkg_dir}: {(out.stderr or out.stdout).strip()}")
|
|
605
|
+
tgz_name = out.stdout.strip().splitlines()[-1]
|
|
606
|
+
extract_dir = os.path.join(tmp, "extracted-" + tgz_name)
|
|
607
|
+
os.makedirs(extract_dir, exist_ok=True)
|
|
608
|
+
with tarfile.open(os.path.join(tmp, tgz_name)) as tf:
|
|
609
|
+
tf.extractall(extract_dir) # noqa: S202 — trusted, just-packed local tarball
|
|
610
|
+
root = os.path.join(extract_dir, "package")
|
|
611
|
+
with open(os.path.join(root, "package.json"), encoding="utf-8") as f:
|
|
612
|
+
pkg_json = json.load(f)
|
|
613
|
+
return root, pkg_json, tgz_name
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _selftest_exports_resolution():
|
|
617
|
+
"""REQ-04 — every specifier the templates emit must resolve, under the
|
|
618
|
+
PACKED artifact's real exports map, to a file that exists in the
|
|
619
|
+
extracted tarball. Packs BOTH @adia-ai/web-components and
|
|
620
|
+
@adia-ai/web-modules at this repo's current lockstep version."""
|
|
621
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
622
|
+
repo_root = os.path.abspath(os.path.join(script_dir, "..", "..", "..", ".."))
|
|
623
|
+
pkg_dirs = {
|
|
624
|
+
"@adia-ai/web-components": os.path.join(repo_root, "packages", "web-components"),
|
|
625
|
+
"@adia-ai/web-modules": os.path.join(repo_root, "packages", "web-modules"),
|
|
626
|
+
}
|
|
627
|
+
ok = True
|
|
628
|
+
by_mode = _emitted_specifiers_by_mode()
|
|
629
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
630
|
+
packed = {}
|
|
631
|
+
for pkg_name, pkg_dir in pkg_dirs.items():
|
|
632
|
+
try:
|
|
633
|
+
packed[pkg_name] = _pack_and_extract(pkg_dir, tmp)
|
|
634
|
+
except Exception as e:
|
|
635
|
+
print(f"selftest: FAIL — could not npm pack {pkg_name}: {e}", file=sys.stderr)
|
|
636
|
+
ok = False
|
|
637
|
+
for mode, specs in by_mode.items():
|
|
638
|
+
for spec in sorted(specs):
|
|
639
|
+
pkg_name, subpath = _split_specifier(spec)
|
|
640
|
+
if pkg_name is None or pkg_name not in packed:
|
|
641
|
+
continue
|
|
642
|
+
root, pkg_json, tgz_name = packed[pkg_name]
|
|
643
|
+
version = pkg_json.get("version", "?")
|
|
644
|
+
rel = _resolve_export(pkg_json, subpath)
|
|
645
|
+
if rel is None:
|
|
646
|
+
print(f"selftest: FAIL — {mode} emits '{spec}'; "
|
|
647
|
+
f"not resolvable in {pkg_name}-{version}.tgz exports", file=sys.stderr)
|
|
648
|
+
ok = False
|
|
649
|
+
continue
|
|
650
|
+
if not os.path.exists(os.path.join(root, rel.lstrip("./"))):
|
|
651
|
+
print(f"selftest: FAIL — {mode} emits '{spec}'; resolves to "
|
|
652
|
+
f"'{rel}' which does not exist in {pkg_name}-{version}.tgz", file=sys.stderr)
|
|
653
|
+
ok = False
|
|
654
|
+
return ok
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _selftest_hint_drift():
|
|
658
|
+
"""REQ-05 — the /adia-scaffold command doc's argument-hint must equal
|
|
659
|
+
the script's OWN argparse mode choices (via _build_parser(), not a
|
|
660
|
+
third hardcoded copy), or the boot journey's first documented command
|
|
661
|
+
can silently name a mode the script rejects (gh#1121's repro:
|
|
662
|
+
`adia-scaffold app my-app` — 'app' was never a real mode)."""
|
|
663
|
+
_, sub = _build_parser()
|
|
664
|
+
choices = set(sub.choices.keys())
|
|
665
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
666
|
+
doc_path = os.path.join(script_dir, "..", "commands", "project-scaffolding.md")
|
|
667
|
+
try:
|
|
668
|
+
with open(doc_path, encoding="utf-8") as f:
|
|
669
|
+
text = f.read()
|
|
670
|
+
except OSError as e:
|
|
671
|
+
print(f"selftest: FAIL — cannot read {doc_path}: {e}", file=sys.stderr)
|
|
672
|
+
return False
|
|
673
|
+
m = re.search(r'argument-hint:\s*"(\[[^\]]*\])', text)
|
|
674
|
+
if not m:
|
|
675
|
+
print(f"selftest: FAIL — {doc_path} has no argument-hint mode-bracket", file=sys.stderr)
|
|
676
|
+
return False
|
|
677
|
+
hinted = set(m.group(1).strip("[]").split("|"))
|
|
678
|
+
if hinted != choices:
|
|
679
|
+
print(f"selftest: FAIL — argument-hint modes {sorted(hinted)} != "
|
|
680
|
+
f"argparse choices {sorted(choices)} ({doc_path})", file=sys.stderr)
|
|
681
|
+
return False
|
|
682
|
+
return True
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _selftest():
|
|
686
|
+
ok = True
|
|
687
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
688
|
+
_, w, _ = _scaffold("spa", "Demo App", None, tmp, False)
|
|
689
|
+
need = [f for f in w if f.endswith(("index.html", "demo-app.js", "demo-app.css"))]
|
|
690
|
+
missing = [n for n in ("index.html", "vite.config.js", "package.json") if not any(f.endswith(n) for f in w)]
|
|
691
|
+
if missing or len(w) < 8:
|
|
692
|
+
print(f"selftest: SPA scaffold incomplete (missing: {missing}, got {len(w)} files)", file=sys.stderr); ok = False
|
|
693
|
+
for fw in ("next", "nuxt", "sveltekit", "astro"):
|
|
694
|
+
_, w2, _ = _scaffold("ssr", f"demo-{fw}", fw, tmp, False)
|
|
695
|
+
if not any("README.md" == f for f in w2) or len(w2) != 2:
|
|
696
|
+
print(f"selftest: SSR/{fw} scaffold incomplete: {w2}", file=sys.stderr); ok = False
|
|
697
|
+
pf, _ = _page_files("Live View", False)
|
|
698
|
+
wpt, _ = _write(os.path.join(tmp, "pg"), pf, False)
|
|
699
|
+
if not (any(f.endswith("live-view.html") for f in wpt) and any(f.endswith("live-view.contents.js") for f in wpt)):
|
|
700
|
+
print("selftest: page-trio incomplete", file=sys.stderr); ok = False
|
|
701
|
+
pfd, _ = _page_files("Static Note", True)
|
|
702
|
+
wpd, _ = _write(os.path.join(tmp, "pgd"), pfd, False)
|
|
703
|
+
if any(f.endswith(".contents.js") for f in wpd) or len(wpd) != 2:
|
|
704
|
+
print("selftest: page-DUO should have no .contents.js", file=sys.stderr); ok = False
|
|
705
|
+
cf, _ = _component_files("data-badge")
|
|
706
|
+
wc, _ = _write(os.path.join(tmp, "cmp"), cf, False)
|
|
707
|
+
if not any(f.endswith(os.path.join("data-badge", "data-badge.js")) for f in wc):
|
|
708
|
+
print("selftest: component incomplete", file=sys.stderr); ok = False
|
|
709
|
+
# inventory: a fresh scaffold's mechanized gates must pass; a broken
|
|
710
|
+
# tree (bare component file, setup-less .contents.js) must fail.
|
|
711
|
+
root, _, _ = _scaffold("spa", "Inv App", None, os.path.join(tmp, "inv"), False)
|
|
712
|
+
import contextlib, io
|
|
713
|
+
buf = io.StringIO()
|
|
714
|
+
with contextlib.redirect_stdout(buf):
|
|
715
|
+
rc_good = _inventory(root)
|
|
716
|
+
if rc_good != 0:
|
|
717
|
+
print(f"selftest: inventory flagged a fresh scaffold:\n{buf.getvalue()}", file=sys.stderr); ok = False
|
|
718
|
+
bad = os.path.join(tmp, "invbad")
|
|
719
|
+
os.makedirs(os.path.join(bad, "app", "components"), exist_ok=True)
|
|
720
|
+
open(os.path.join(bad, "app", "components", "loose.js"), "w").write("// bare\n")
|
|
721
|
+
open(os.path.join(bad, "app", "broken.contents.js"), "w").write("// no setup here\n")
|
|
722
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
723
|
+
rc_bad = _inventory(bad)
|
|
724
|
+
if rc_bad == 0:
|
|
725
|
+
print("selftest: inventory passed a broken tree", file=sys.stderr); ok = False
|
|
726
|
+
# REQ-05 — command-doc argument-hint must equal the real argparse modes.
|
|
727
|
+
if not _selftest_hint_drift():
|
|
728
|
+
ok = False
|
|
729
|
+
# REQ-04 — every emitted specifier must resolve against BOTH packed,
|
|
730
|
+
# extracted tarballs' real exports maps (gh#1120's blast radius).
|
|
731
|
+
if not _selftest_exports_resolution():
|
|
732
|
+
ok = False
|
|
733
|
+
print("selftest: PASS" if ok else "selftest: FAIL")
|
|
734
|
+
return 0 if ok else 1
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _build_parser():
|
|
738
|
+
"""The one parser both main() and the selftest's hint-drift check
|
|
739
|
+
(REQ-05) use — `sub.choices` is the single source of the real mode
|
|
740
|
+
list, so the drift check can never itself drift from what main()
|
|
741
|
+
actually accepts."""
|
|
742
|
+
p = argparse.ArgumentParser(prog="adia-scaffold", add_help=True,
|
|
743
|
+
description="Lay the minimal bones of an adia-ui app.")
|
|
744
|
+
sub = p.add_subparsers(dest="mode", required=True)
|
|
745
|
+
for m in ("spa", "ssr"):
|
|
746
|
+
sp = sub.add_parser(m)
|
|
747
|
+
sp.add_argument("name")
|
|
748
|
+
sp.add_argument("-o", "--out", default=".")
|
|
749
|
+
sp.add_argument("--force", action="store_true")
|
|
750
|
+
if m == "ssr":
|
|
751
|
+
sp.add_argument("--framework", required=True,
|
|
752
|
+
choices=sorted(_SSR.keys()))
|
|
753
|
+
pg = sub.add_parser("page")
|
|
754
|
+
pg.add_argument("name")
|
|
755
|
+
pg.add_argument("-o", "--out", default=".")
|
|
756
|
+
pg.add_argument("--duo", action="store_true")
|
|
757
|
+
pg.add_argument("--force", action="store_true")
|
|
758
|
+
cm = sub.add_parser("component")
|
|
759
|
+
cm.add_argument("name")
|
|
760
|
+
cm.add_argument("-o", "--out", default=".")
|
|
761
|
+
cm.add_argument("--force", action="store_true")
|
|
762
|
+
inv = sub.add_parser("inventory", help="score an app dir against the structure rubric")
|
|
763
|
+
inv.add_argument("app_root", nargs="?", default=".")
|
|
764
|
+
sub.add_parser("selftest")
|
|
765
|
+
return p, sub
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def main(argv):
|
|
769
|
+
p, _sub = _build_parser()
|
|
770
|
+
args = p.parse_args(argv)
|
|
771
|
+
|
|
772
|
+
if args.mode == "selftest":
|
|
773
|
+
return _selftest()
|
|
774
|
+
|
|
775
|
+
if args.mode == "inventory":
|
|
776
|
+
return _inventory(args.app_root)
|
|
777
|
+
|
|
778
|
+
if args.mode == "page":
|
|
779
|
+
files, _ = _page_files(args.name, args.duo)
|
|
780
|
+
root, label = args.out, ("PAGE/DUO" if args.duo else "PAGE")
|
|
781
|
+
written, skipped = _write(root, files, args.force)
|
|
782
|
+
elif args.mode == "component":
|
|
783
|
+
files, _ = _component_files(args.name)
|
|
784
|
+
root, label = args.out, "COMPONENT"
|
|
785
|
+
written, skipped = _write(root, files, args.force)
|
|
786
|
+
else:
|
|
787
|
+
framework = getattr(args, "framework", None)
|
|
788
|
+
root, written, skipped = _scaffold(args.mode, args.name, framework, args.out, args.force)
|
|
789
|
+
label = args.mode.upper() + (f"/{framework}" if framework else "")
|
|
790
|
+
print(f"adia-scaffold [{label}] → {root}")
|
|
791
|
+
for f in written:
|
|
792
|
+
print(f" + {f}")
|
|
793
|
+
for f in skipped:
|
|
794
|
+
print(f" · {f} (exists; --force to overwrite)")
|
|
795
|
+
if not written:
|
|
796
|
+
print(" (nothing written)")
|
|
797
|
+
return 0
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
if __name__ == "__main__":
|
|
801
|
+
sys.exit(main(sys.argv[1:]))
|