create-substrat 0.4.2 → 0.6.0
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/dev-servers.js +29 -0
- package/index.js +12 -1
- package/package.json +2 -1
- package/template/.claude/launch.json +15 -0
- package/template/.claude/settings.json +15 -0
- package/template/.substrat/hooks/session-start.mjs +139 -0
package/dev-servers.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev servers a scaffolded vertical runs — declared once, client-neutrally.
|
|
3
|
+
*
|
|
4
|
+
* `.claude/launch.json` is a Claude Desktop **adapter**: it lets the agent start
|
|
5
|
+
* the server, open it in the Browser pane, and verify its own changes. Under
|
|
6
|
+
* design/agent-surface.md §3 an adapter may route, describe and trigger but may
|
|
7
|
+
* never *hold* substance — so the topology lives here, in the `substrat` block of
|
|
8
|
+
* package.json that `substrat push` and the SessionStart hook already read, and
|
|
9
|
+
* the client file is emitted from it (`pnpm lint:launch`, guarded in CI).
|
|
10
|
+
*
|
|
11
|
+
* A port is deliberately NOT a field. `portEnv` + `portFrom` say *where* the port
|
|
12
|
+
* is bound; the number is read out of that file. Otherwise the declaration becomes
|
|
13
|
+
* a second copy of a number whose first copy is the one that actually runs, and
|
|
14
|
+
* with `autoPort: false` a stale copy is a hard boot failure — for an agent, mid
|
|
15
|
+
* session, which is the worst possible audience for it.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {object} DevServer
|
|
18
|
+
* @property {string} name Entry name. Mirrors the `-n` label of the `dev` script.
|
|
19
|
+
* @property {string} run pnpm script to run. With `dir`, the script in that subdir.
|
|
20
|
+
* @property {string} [dir] Subdirectory of the project (a Vite app), if any.
|
|
21
|
+
* @property {string} portEnv The env var that moves this port (`PORT`, `WEB_PORT`, …).
|
|
22
|
+
* @property {string} portFrom Project-relative file binding it: `process.env.<portEnv> ?? N`.
|
|
23
|
+
* @property {Record<string,string>} [env] Env the `dev` script sets for this process.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The template ships one process: the Hono API. There is no web app to scaffold yet. */
|
|
27
|
+
export const DEV_SERVERS = [
|
|
28
|
+
{ name: 'api', run: 'server', portEnv: 'PORT', portFrom: 'src/server.ts' },
|
|
29
|
+
];
|
package/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:
|
|
|
16
16
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
|
|
19
|
+
import { DEV_SERVERS } from './dev-servers.js';
|
|
20
|
+
|
|
19
21
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const TEMPLATE = join(HERE, 'template');
|
|
21
23
|
|
|
@@ -73,7 +75,9 @@ function packageJson(name) {
|
|
|
73
75
|
// What `substrat push` reads: the permission surface (the registry the
|
|
74
76
|
// promotion checkpoint diffs) and the runtime needs the deploy config is
|
|
75
77
|
// derived from — you never author wrangler config (src/worker.ts is the
|
|
76
|
-
// entry; ScopeDO is the store it exports).
|
|
78
|
+
// entry; ScopeDO is the store it exports). Also the client-neutral home of
|
|
79
|
+
// the dev topology `.claude/launch.json` is emitted from — see
|
|
80
|
+
// dev-servers.js for why the port is read from the code and never declared.
|
|
77
81
|
substrat: {
|
|
78
82
|
permissions: 'src/provision.ts',
|
|
79
83
|
runtimeNeeds: {
|
|
@@ -84,6 +88,7 @@ function packageJson(name) {
|
|
|
84
88
|
{ binding: 'SWEEPER', class: 'SweeperDO' },
|
|
85
89
|
],
|
|
86
90
|
},
|
|
91
|
+
devServers: DEV_SERVERS,
|
|
87
92
|
},
|
|
88
93
|
scripts: {
|
|
89
94
|
dev: 'tsx watch src/server.ts',
|
|
@@ -162,6 +167,12 @@ dist/
|
|
|
162
167
|
*.db
|
|
163
168
|
.data/
|
|
164
169
|
.DS_Store
|
|
170
|
+
|
|
171
|
+
# Which kernel version this checkout last announced to an agent (.substrat/hooks
|
|
172
|
+
# /session-start.mjs). Per-checkout state, not a project fact.
|
|
173
|
+
.substrat/.docs-pin
|
|
174
|
+
# Opt out of that hook entirely by creating this file.
|
|
175
|
+
.substrat/no-session-context
|
|
165
176
|
`;
|
|
166
177
|
|
|
167
178
|
function readme(name) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-substrat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"index.js",
|
|
27
|
+
"dev-servers.js",
|
|
27
28
|
"template"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/.substrat/hooks/session-start.mjs\""
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart hook — the project announces itself (#754).
|
|
4
|
+
*
|
|
5
|
+
* The smooth part of a framework's agent integration is not the skills, it is
|
|
6
|
+
* that the user never has to remember the framework *has* an integration. This
|
|
7
|
+
* runs when an agent session starts, and if the project is a Substrat vertical
|
|
8
|
+
* it hands the agent three things it would otherwise have to discover: what this
|
|
9
|
+
* project is, where the rules live, and **which version of the docs describes the
|
|
10
|
+
* kernel actually installed here**.
|
|
11
|
+
*
|
|
12
|
+
* That last one is the reason this exists. Substrat is 0.x and interfaces change
|
|
13
|
+
* without notice, so an agent working from pages it cached two minors ago is the
|
|
14
|
+
* expensive failure — confident, plausible, and wrong. `llms.txt` is published at
|
|
15
|
+
* a version-pinned URL precisely so this hook can point at the matching slice.
|
|
16
|
+
*
|
|
17
|
+
* ## Deliberately not tool-specific
|
|
18
|
+
*
|
|
19
|
+
* This file lives in `.substrat/` — the tool-neutral home, next to `playbook.md` —
|
|
20
|
+
* not in `.claude/`. `.claude/settings.json` is a three-line adapter that runs it,
|
|
21
|
+
* and any other client that grows a session hook binds the same way. It also
|
|
22
|
+
* means the plugin distribution (#753) can ship this script unchanged rather than
|
|
23
|
+
* forking it.
|
|
24
|
+
*
|
|
25
|
+
* ## Deliberately silent, and deliberately offline
|
|
26
|
+
*
|
|
27
|
+
* It prints nothing at all unless `package.json` has a `substrat` block, so it is
|
|
28
|
+
* inert in any other project. And it makes **no network request**: session start
|
|
29
|
+
* is on the critical path of every session and is frequently offline, while the
|
|
30
|
+
* check a fetch would perform — does the published doc set still describe my
|
|
31
|
+
* kernel — is already mechanical for whoever actually fetches, because
|
|
32
|
+
* `/llms-<version>.txt` 404s exactly when the answer is no.
|
|
33
|
+
*
|
|
34
|
+
* Opt out by creating `.substrat/no-session-context`.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const DOCS = 'https://substrat.net';
|
|
40
|
+
const KERNEL = '@substrat-run/kernel';
|
|
41
|
+
|
|
42
|
+
/** Where the project is. Claude Code sets this; fall back to the cwd it ran us in. */
|
|
43
|
+
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
44
|
+
|
|
45
|
+
/** The marker: which kernel version this project last announced. */
|
|
46
|
+
const MARKER = join(root, '.substrat', '.docs-pin');
|
|
47
|
+
const OPT_OUT = join(root, '.substrat', 'no-session-context');
|
|
48
|
+
|
|
49
|
+
/** Nothing this hook does is worth failing a session over. */
|
|
50
|
+
function silent() {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJson(path) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function main() {
|
|
63
|
+
if (existsSync(OPT_OUT)) silent();
|
|
64
|
+
|
|
65
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
66
|
+
// The detection signal is the block `substrat push` already reads — no sentinel
|
|
67
|
+
// file to invent, and nothing to keep in sync.
|
|
68
|
+
if (!pkg?.substrat) silent();
|
|
69
|
+
|
|
70
|
+
if (existsSync(OPT_OUT)) silent();
|
|
71
|
+
|
|
72
|
+
// What is actually installed beats what package.json asked for: a caret range on
|
|
73
|
+
// 0.x pins the minor, and the resolved version is what the code compiles against.
|
|
74
|
+
const installed = readJson(join(root, 'node_modules', KERNEL, 'package.json'))?.version;
|
|
75
|
+
const declared = pkg.dependencies?.[KERNEL] ?? pkg.devDependencies?.[KERNEL];
|
|
76
|
+
|
|
77
|
+
const lines = [
|
|
78
|
+
'This project is a **Substrat vertical** — a multi-tenant business app on the',
|
|
79
|
+
'Substrat kernel and its engines.',
|
|
80
|
+
'',
|
|
81
|
+
'- The always-on rules you must not violate are in `AGENTS.md` — module-code',
|
|
82
|
+
' boundaries, the gates, and the two checkpoints you may never self-approve.',
|
|
83
|
+
'- The build flow (interview → design → reshape → checkpoints) is `.substrat/playbook.md`.',
|
|
84
|
+
' It is a playbook, not always-on context: read it when starting or extending a vertical.',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
if (installed) {
|
|
88
|
+
lines.push(
|
|
89
|
+
'',
|
|
90
|
+
`This project has **${KERNEL} ${installed}** installed.`,
|
|
91
|
+
'',
|
|
92
|
+
`Docs describing that exact version: ${DOCS}/llms-${installed}.txt`,
|
|
93
|
+
'',
|
|
94
|
+
'That URL returns 200 only while the published docs still describe this kernel. A 404',
|
|
95
|
+
`means they have moved on — fetch ${DOCS}/llms.txt instead, and treat anything you`,
|
|
96
|
+
'already believe about the API as unverified. Substrat is pre-1.0 and interfaces change',
|
|
97
|
+
'without notice, so do not answer from memory about its surface; the pages are markdown',
|
|
98
|
+
'at those URLs and every doc page has a `.md` twin.',
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const announced = existsSync(MARKER) ? readFileSync(MARKER, 'utf8').trim() : '';
|
|
102
|
+
if (announced && announced !== installed) {
|
|
103
|
+
lines.unshift(
|
|
104
|
+
`**The kernel moved: ${announced} → ${installed} since the last session here.**`,
|
|
105
|
+
'Re-read the docs slice below before relying on anything you remember about the API.',
|
|
106
|
+
'',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(dirname(MARKER), { recursive: true });
|
|
111
|
+
writeFileSync(MARKER, `${installed}\n`);
|
|
112
|
+
} catch {
|
|
113
|
+
// A read-only checkout still gets the context; it just re-announces next time.
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
lines.push(
|
|
117
|
+
'',
|
|
118
|
+
`**${KERNEL} is not installed yet** (\`package.json\` asks for \`${declared ?? 'it'}\`).`,
|
|
119
|
+
'Run the install before trusting any version-specific guidance, then re-check the docs',
|
|
120
|
+
`index at ${DOCS}/llms.txt.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
`${JSON.stringify({
|
|
126
|
+
hookSpecificOutput: {
|
|
127
|
+
hookEventName: 'SessionStart',
|
|
128
|
+
additionalContext: lines.join('\n'),
|
|
129
|
+
},
|
|
130
|
+
})}\n`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
main();
|
|
136
|
+
} catch {
|
|
137
|
+
// Never break a session because orientation failed.
|
|
138
|
+
silent();
|
|
139
|
+
}
|