@inbeat/standards 0.7.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/CHANGELOG.md +100 -0
- package/agents/app-section.md +22 -0
- package/agents/standard.md +114 -0
- package/package.json +46 -0
- package/skills/ib-feature/SKILL.md +40 -0
- package/skills/ib-toolkit/SKILL.md +125 -0
- package/skills/ib-ui/SKILL.md +155 -0
- package/src/cli.ts +85 -0
- package/src/create-args.ts +10 -0
- package/src/create.ts +240 -0
- package/src/foundation.ts +42 -0
- package/src/mcp-servers.ts +8 -0
- package/src/mcp.ts +383 -0
- package/src/sync.ts +117 -0
- package/src/template.ts +34 -0
- package/template/.agents/skills/ib-feature/SKILL.md +40 -0
- package/template/.agents/skills/ib-toolkit/SKILL.md +125 -0
- package/template/.agents/skills/ib-ui/SKILL.md +155 -0
- package/template/.dependency-cruiser.cjs +1 -0
- package/template/.env.example +12 -0
- package/template/.github/workflows/ci.yml +14 -0
- package/template/AGENTS.md +139 -0
- package/template/_gitignore +10 -0
- package/template/biome.json +6 -0
- package/template/next.config.ts +32 -0
- package/template/package.json +62 -0
- package/template/postcss.config.mjs +7 -0
- package/template/sentry.edge.config.ts +11 -0
- package/template/sentry.server.config.ts +12 -0
- package/template/src/app/api/health/route.test.ts +12 -0
- package/template/src/app/api/health/route.ts +7 -0
- package/template/src/app/error.tsx +24 -0
- package/template/src/app/global-error.tsx +20 -0
- package/template/src/app/globals.css +4 -0
- package/template/src/app/icon.svg +1 -0
- package/template/src/app/layout.tsx +24 -0
- package/template/src/app/loading.tsx +9 -0
- package/template/src/app/not-found.tsx +14 -0
- package/template/src/app/page.tsx +7 -0
- package/template/src/app/robots.ts +6 -0
- package/template/src/instrumentation-client.ts +14 -0
- package/template/src/instrumentation.ts +13 -0
- package/template/src/shared/config/env.ts +19 -0
- package/template/src/shared/config/routes.ts +3 -0
- package/template/src/test/server-only.ts +1 -0
- package/template/tsconfig.json +10 -0
- package/template/vitest.config.ts +14 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import type { Readable, Writable } from "node:stream";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { CURATED_SERVERS } from "./mcp-servers";
|
|
7
|
+
|
|
8
|
+
type Entry = (url: string) => Record<string, string>;
|
|
9
|
+
type Applied = { text: string; notes: string[] };
|
|
10
|
+
type Format = {
|
|
11
|
+
file: string;
|
|
12
|
+
configured: (text: string) => string[];
|
|
13
|
+
apply: (text: string | null, names: string[]) => Applied;
|
|
14
|
+
};
|
|
15
|
+
type Table = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
const curatedNames = Object.keys(CURATED_SERVERS);
|
|
18
|
+
const isCurated = (name: string) => name in CURATED_SERVERS;
|
|
19
|
+
const isTable = (value: unknown): value is Table =>
|
|
20
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
const detectEol = (text: string) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
22
|
+
const keptNote = (name: string) => `${name} has custom settings; kept`;
|
|
23
|
+
|
|
24
|
+
const JSON_REFUSAL = "contains comments or is not a JSON object; edit it by hand";
|
|
25
|
+
const BOM = "\uFEFF";
|
|
26
|
+
|
|
27
|
+
function readJson(text: string | null, key: string) {
|
|
28
|
+
const bom = text?.startsWith(BOM) ? BOM : "";
|
|
29
|
+
const body = (text ?? "").slice(bom.length);
|
|
30
|
+
let config: unknown = {};
|
|
31
|
+
if (body.trim()) {
|
|
32
|
+
try {
|
|
33
|
+
config = JSON.parse(body);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(JSON_REFUSAL);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const servers = isTable(config) ? (config[key] === undefined ? {} : config[key]) : null;
|
|
39
|
+
if (!isTable(config) || !isTable(servers)) throw new Error(JSON_REFUSAL);
|
|
40
|
+
return { bom, config, servers };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function jsonFormat(file: string, key: string, entry: Entry): Format {
|
|
44
|
+
const managedKeys = Object.keys(entry(""));
|
|
45
|
+
return {
|
|
46
|
+
file,
|
|
47
|
+
configured: (text) => Object.keys(readJson(text, key).servers).filter(isCurated),
|
|
48
|
+
apply: (text, names) => {
|
|
49
|
+
const { bom, config, servers } = readJson(text, key);
|
|
50
|
+
const notes: string[] = [];
|
|
51
|
+
const next: Table = {};
|
|
52
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
53
|
+
if (!isCurated(name)) next[name] = value;
|
|
54
|
+
else if (names.includes(name))
|
|
55
|
+
next[name] = { ...(isTable(value) ? value : {}), ...entry(CURATED_SERVERS[name]) };
|
|
56
|
+
else if (isTable(value) && Object.keys(value).every((field) => managedKeys.includes(field)))
|
|
57
|
+
continue;
|
|
58
|
+
else {
|
|
59
|
+
next[name] = value;
|
|
60
|
+
notes.push(keptNote(name));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const name of names) next[name] ??= entry(CURATED_SERVERS[name]);
|
|
64
|
+
const eol = detectEol(text ?? "");
|
|
65
|
+
const json = `${JSON.stringify({ ...config, [key]: next }, null, 2)}\n`;
|
|
66
|
+
return { text: bom + json.replaceAll("\n", eol), notes };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseToml(text: string): { root: Table; servers: Table } {
|
|
72
|
+
let root: unknown;
|
|
73
|
+
try {
|
|
74
|
+
root = Bun.TOML.parse(text);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("is not valid TOML; edit it by hand");
|
|
77
|
+
}
|
|
78
|
+
const servers = (root as Table).mcp_servers ?? {};
|
|
79
|
+
if (!isTable(servers)) throw new Error("mcp_servers is not a table; edit it by hand");
|
|
80
|
+
return { root: root as Table, servers };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const HEADER_LINE = /^\s*\[/;
|
|
84
|
+
const tableHeader = (name: string) =>
|
|
85
|
+
new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*(?:${name}|"${name}")\\s*\\](?:\\s*#.*)?\\s*$`);
|
|
86
|
+
|
|
87
|
+
function locateTable(lines: string[], name: string): { start: number; end: number } {
|
|
88
|
+
const matches = lines.flatMap((line, index) => (tableHeader(name).test(line) ? [index] : []));
|
|
89
|
+
if (matches.length !== 1)
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${name} is defined inline/dotted; edit it by hand or convert to a [mcp_servers.${name}] table`,
|
|
92
|
+
);
|
|
93
|
+
const start = matches[0];
|
|
94
|
+
let end = lines.findIndex((line, index) => index > start && HEADER_LINE.test(line));
|
|
95
|
+
if (end === -1) end = lines.length;
|
|
96
|
+
return { start, end };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const withEntries = (root: Table, servers: Table): Table => {
|
|
100
|
+
const { mcp_servers: _, ...rest } = root;
|
|
101
|
+
return Object.keys(servers).length > 0 ? { ...rest, mcp_servers: servers } : rest;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const tomlFormat: Format = {
|
|
105
|
+
file: join(".codex", "config.toml"),
|
|
106
|
+
configured: (text) => Object.keys(parseToml(text).servers).filter(isCurated),
|
|
107
|
+
apply: (text, names) => {
|
|
108
|
+
const source = text ?? "";
|
|
109
|
+
const eol = detectEol(source);
|
|
110
|
+
const { root, servers } = parseToml(source);
|
|
111
|
+
const lines = source.split(/(?<=\n)/).filter(Boolean);
|
|
112
|
+
const expected: Table = { ...servers };
|
|
113
|
+
const notes: string[] = [];
|
|
114
|
+
const added: string[] = [];
|
|
115
|
+
let changed = false;
|
|
116
|
+
|
|
117
|
+
for (const name of curatedNames) {
|
|
118
|
+
const selected = names.includes(name);
|
|
119
|
+
const existing = servers[name];
|
|
120
|
+
const url = CURATED_SERVERS[name];
|
|
121
|
+
if (existing === undefined) {
|
|
122
|
+
if (selected) {
|
|
123
|
+
added.push(name);
|
|
124
|
+
expected[name] = { url };
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const { start, end } = locateTable(lines, name);
|
|
129
|
+
if (selected) {
|
|
130
|
+
expected[name] = { ...(isTable(existing) ? existing : {}), url };
|
|
131
|
+
if (isTable(existing) && existing.url === url) continue;
|
|
132
|
+
const urlLine = `url = "${url}"`;
|
|
133
|
+
const at = lines.findIndex(
|
|
134
|
+
(line, index) => index > start && index < end && /^\s*url\s*=/.test(line),
|
|
135
|
+
);
|
|
136
|
+
if (at === -1) lines.splice(start + 1, 0, urlLine + eol);
|
|
137
|
+
else lines[at] = urlLine + (/\r?\n$/.exec(lines[at])?.[0] ?? "");
|
|
138
|
+
changed = true;
|
|
139
|
+
} else if (isTable(existing) && Object.keys(existing).every((field) => field === "url")) {
|
|
140
|
+
let removeEnd = end;
|
|
141
|
+
while (removeEnd > start + 1 && /^\s*#/.test(lines[removeEnd - 1])) removeEnd--;
|
|
142
|
+
lines.splice(start, removeEnd - start);
|
|
143
|
+
delete expected[name];
|
|
144
|
+
changed = true;
|
|
145
|
+
} else {
|
|
146
|
+
notes.push(keptNote(name));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!changed && added.length === 0) return { text: source, notes };
|
|
151
|
+
|
|
152
|
+
const head = lines.join("");
|
|
153
|
+
const tables = added.map(
|
|
154
|
+
(name) => `[mcp_servers.${name}]${eol}url = "${CURATED_SERVERS[name]}"${eol}`,
|
|
155
|
+
);
|
|
156
|
+
const separator = head && tables.length ? (head.endsWith("\n") ? eol : eol + eol) : "";
|
|
157
|
+
const result = head + separator + tables.join(eol);
|
|
158
|
+
|
|
159
|
+
let actual: ReturnType<typeof parseToml>;
|
|
160
|
+
try {
|
|
161
|
+
actual = parseToml(result);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error("could not be edited safely; edit it by hand");
|
|
164
|
+
}
|
|
165
|
+
if (!isDeepStrictEqual(withEntries(actual.root, actual.servers), withEntries(root, expected)))
|
|
166
|
+
throw new Error("could not be edited safely; edit it by hand");
|
|
167
|
+
return { text: result, notes };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const TOOLS: Record<string, Format> = {
|
|
172
|
+
claude: jsonFormat(".mcp.json", "mcpServers", (url) => ({ type: "http", url })),
|
|
173
|
+
cursor: jsonFormat(join(".cursor", "mcp.json"), "mcpServers", (url) => ({ url })),
|
|
174
|
+
vscode: jsonFormat(join(".vscode", "mcp.json"), "servers", (url) => ({ type: "http", url })),
|
|
175
|
+
codex: tomlFormat,
|
|
176
|
+
// Gemini CLI reads `httpUrl` as streamable HTTP; `url` is SSE. https://geminicli.com/docs/tools/mcp-server/
|
|
177
|
+
gemini: jsonFormat(join(".gemini", "settings.json"), "mcpServers", (url) => ({ httpUrl: url })),
|
|
178
|
+
};
|
|
179
|
+
const toolNames = Object.keys(TOOLS);
|
|
180
|
+
|
|
181
|
+
export const applyServers = (tool: string, text: string | null, names: string[]) =>
|
|
182
|
+
TOOLS[tool].apply(text, names);
|
|
183
|
+
|
|
184
|
+
export const configuredServers = (tool: string, text: string | null) =>
|
|
185
|
+
text ? TOOLS[tool].configured(text) : [];
|
|
186
|
+
|
|
187
|
+
export const USAGE = [
|
|
188
|
+
"usage: ib mcp [--tools <tool,...>] [--servers <server,...>]",
|
|
189
|
+
` tools: ${toolNames.join(", ")}`,
|
|
190
|
+
` servers: ${curatedNames.join(", ")}`,
|
|
191
|
+
" without a terminal both flags are required; otherwise missing ones are prompted",
|
|
192
|
+
].join("\n");
|
|
193
|
+
|
|
194
|
+
class UsageError extends Error {}
|
|
195
|
+
class Aborted extends Error {}
|
|
196
|
+
|
|
197
|
+
const FLAG = /^--(tools|servers)(?:=(.*))?$/;
|
|
198
|
+
const splitList = (value: string) =>
|
|
199
|
+
value
|
|
200
|
+
.split(",")
|
|
201
|
+
.map((item) => item.trim())
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
|
|
204
|
+
export function parseMcpArgs(args: string[]): { tools?: string[]; servers?: string[] } {
|
|
205
|
+
const parsed: { tools?: string[]; servers?: string[] } = {};
|
|
206
|
+
for (let index = 0; index < args.length; index++) {
|
|
207
|
+
const match = FLAG.exec(args[index]);
|
|
208
|
+
if (!match) throw new UsageError(`unknown option ${args[index]}`);
|
|
209
|
+
const value = match[2] ?? args[++index];
|
|
210
|
+
if (value === undefined) throw new UsageError(`${args[index - 1]} needs a value`);
|
|
211
|
+
const list = splitList(value);
|
|
212
|
+
if (list.length === 0) throw new UsageError(`--${match[1]} needs at least one value`);
|
|
213
|
+
parsed[match[1] as "tools" | "servers"] = list;
|
|
214
|
+
}
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const unknownNames = (label: string, names: string[], valid: string[]) => {
|
|
219
|
+
const unknown = names.filter((name) => !valid.includes(name));
|
|
220
|
+
return unknown.length > 0
|
|
221
|
+
? `unknown ${label} ${unknown.join(", ")}; valid: ${valid.join(", ")}`
|
|
222
|
+
: null;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function validate(label: string, names: string[], valid: string[]) {
|
|
226
|
+
const problem = unknownNames(label, names, valid);
|
|
227
|
+
if (problem) throw new UsageError(problem);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type McpIo = {
|
|
231
|
+
cwd: string;
|
|
232
|
+
stdin: Readable;
|
|
233
|
+
output: Writable;
|
|
234
|
+
isTTY: boolean;
|
|
235
|
+
out: (text: string) => void;
|
|
236
|
+
err: (text: string) => void;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const ATTEMPTS = 2;
|
|
240
|
+
const PLAIN_INTEGER = /^\d+$/;
|
|
241
|
+
|
|
242
|
+
function prompter(io: McpIo) {
|
|
243
|
+
const lines = createInterface({ input: io.stdin, output: io.output, terminal: true });
|
|
244
|
+
const nextLine = lines[Symbol.asyncIterator]();
|
|
245
|
+
// The workspace resolves two @types/node majors; readline's Interface loses its EventEmitter methods.
|
|
246
|
+
const emitter = lines as unknown as { once(event: "SIGINT", listener: () => void): void };
|
|
247
|
+
const interrupted = new Promise<never>((_, reject) =>
|
|
248
|
+
emitter.once("SIGINT", () => reject(new Aborted())),
|
|
249
|
+
);
|
|
250
|
+
interrupted.catch(() => {});
|
|
251
|
+
|
|
252
|
+
const ask = async (label: string, valid: string[], preselected: string[]) => {
|
|
253
|
+
let problem = "";
|
|
254
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
255
|
+
io.out(`${label}:`);
|
|
256
|
+
for (const [index, name] of valid.entries())
|
|
257
|
+
io.out(` ${index + 1}) [${preselected.includes(name) ? "x" : " "}] ${name}`);
|
|
258
|
+
io.out(
|
|
259
|
+
`numbers or names, comma-separated; Enter keeps: ${preselected.join(", ") || "none"}; "none" clears`,
|
|
260
|
+
);
|
|
261
|
+
const { value, done } = await Promise.race([nextLine.next(), interrupted]);
|
|
262
|
+
if (done) throw new Aborted();
|
|
263
|
+
const answer = value.trim();
|
|
264
|
+
if (!answer) return preselected;
|
|
265
|
+
if (answer === "none") return [];
|
|
266
|
+
const names = splitList(answer).map((token) =>
|
|
267
|
+
PLAIN_INTEGER.test(token) ? (valid[Number(token) - 1] ?? token) : token,
|
|
268
|
+
);
|
|
269
|
+
problem = unknownNames(label, names, valid) ?? "";
|
|
270
|
+
if (!problem) return names;
|
|
271
|
+
io.err(problem);
|
|
272
|
+
}
|
|
273
|
+
throw new UsageError(problem);
|
|
274
|
+
};
|
|
275
|
+
return { ask, close: () => lines.close() };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const readIfExists = (path: string) => (existsSync(path) ? readFileSync(path, "utf8") : null);
|
|
279
|
+
|
|
280
|
+
function currentlyConfigured(cwd: string) {
|
|
281
|
+
const byTool = toolNames.map((tool) => {
|
|
282
|
+
try {
|
|
283
|
+
return { tool, names: configuredServers(tool, readIfExists(join(cwd, TOOLS[tool].file))) };
|
|
284
|
+
} catch {
|
|
285
|
+
return { tool, names: [] };
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
tools: byTool.filter(({ names }) => names.length > 0).map(({ tool }) => tool),
|
|
290
|
+
servers: curatedNames.filter((name) => byTool.some(({ names }) => names.includes(name))),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type Planned = {
|
|
295
|
+
file: string;
|
|
296
|
+
path: string;
|
|
297
|
+
before: string | null;
|
|
298
|
+
after: string;
|
|
299
|
+
notes: string[];
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
function plan(cwd: string, tools: string[], servers: string[]) {
|
|
303
|
+
const failures: string[] = [];
|
|
304
|
+
const planned = tools.flatMap((tool): Planned[] => {
|
|
305
|
+
const { file } = TOOLS[tool];
|
|
306
|
+
const path = join(cwd, file);
|
|
307
|
+
try {
|
|
308
|
+
const before = readIfExists(path);
|
|
309
|
+
const { text, notes } = TOOLS[tool].apply(before, servers);
|
|
310
|
+
return [{ file, path, before, after: text, notes }];
|
|
311
|
+
} catch (error) {
|
|
312
|
+
failures.push(`${file}: ${error instanceof Error ? error.message : error}`);
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return { planned, failures };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function writeAtomically(path: string, text: string) {
|
|
320
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
321
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
322
|
+
try {
|
|
323
|
+
writeFileSync(temporary, text);
|
|
324
|
+
renameSync(temporary, path);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
rmSync(temporary, { force: true });
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function runMcp(args: string[], io: McpIo): Promise<number> {
|
|
332
|
+
if (args.some((arg) => arg === "--help" || arg === "-h")) {
|
|
333
|
+
io.out(USAGE);
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
let prompt: ReturnType<typeof prompter> | undefined;
|
|
337
|
+
try {
|
|
338
|
+
const { tools: toolFlag, servers: serverFlag } = parseMcpArgs(args);
|
|
339
|
+
if (!io.isTTY && !(toolFlag && serverFlag)) {
|
|
340
|
+
io.err(USAGE);
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
if (toolFlag) validate("tool", toolFlag, toolNames);
|
|
344
|
+
if (serverFlag) validate("server", serverFlag, curatedNames);
|
|
345
|
+
if (!existsSync(join(io.cwd, "package.json")))
|
|
346
|
+
throw new Error("no package.json here; run `ib mcp` from the app directory");
|
|
347
|
+
|
|
348
|
+
let tools = toolFlag;
|
|
349
|
+
let servers = serverFlag;
|
|
350
|
+
if (!tools || !servers) {
|
|
351
|
+
const configured = currentlyConfigured(io.cwd);
|
|
352
|
+
prompt = prompter(io);
|
|
353
|
+
tools ??= await prompt.ask("tool", toolNames, configured.tools);
|
|
354
|
+
servers ??= await prompt.ask("server", curatedNames, configured.servers);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const { planned, failures } = plan(io.cwd, tools, servers);
|
|
358
|
+
if (failures.length > 0) {
|
|
359
|
+
for (const failure of failures) io.err(failure);
|
|
360
|
+
return 1;
|
|
361
|
+
}
|
|
362
|
+
const changed = planned.filter(({ before, after }) => after !== before);
|
|
363
|
+
for (const { path, after } of changed) writeAtomically(path, after);
|
|
364
|
+
for (const { file, notes } of planned) for (const note of notes) io.out(`${file}: ${note}`);
|
|
365
|
+
io.out(
|
|
366
|
+
changed.length
|
|
367
|
+
? `updated:\n ${changed.map(({ file }) => file).join("\n ")}`
|
|
368
|
+
: "already up to date",
|
|
369
|
+
);
|
|
370
|
+
io.out("authenticate each server in your AI tool on first use");
|
|
371
|
+
return 0;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error instanceof Aborted) {
|
|
374
|
+
io.err("aborted");
|
|
375
|
+
return 130;
|
|
376
|
+
}
|
|
377
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
378
|
+
io.err(error instanceof UsageError ? `${message}\n${USAGE}` : message);
|
|
379
|
+
return 1;
|
|
380
|
+
} finally {
|
|
381
|
+
prompt?.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
export const VERSION: string = JSON.parse(
|
|
15
|
+
readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"),
|
|
16
|
+
).version;
|
|
17
|
+
|
|
18
|
+
export const BLOCK_START = "<!-- ib:standard:start";
|
|
19
|
+
export const BLOCK_END = "<!-- ib:standard:end -->";
|
|
20
|
+
|
|
21
|
+
export type PlannedFile = { path: string; contents: string; mode: "replace" | "managed-block" };
|
|
22
|
+
|
|
23
|
+
function readAsset(...segments: string[]): string {
|
|
24
|
+
return readFileSync(join(PACKAGE_ROOT, ...segments), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function managedBlock(): string {
|
|
28
|
+
return [
|
|
29
|
+
`${BLOCK_START} — generated by @inbeat/standards, edit in ib-toolkit -->`,
|
|
30
|
+
readAsset("agents", "standard.md").trim(),
|
|
31
|
+
BLOCK_END,
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function applyManagedBlock(existing: string | null): string {
|
|
36
|
+
const block = managedBlock();
|
|
37
|
+
if (existing === null)
|
|
38
|
+
return `# AGENTS.md\n\n${block}\n\n${readAsset("agents", "app-section.md")}`;
|
|
39
|
+
const start = existing.indexOf(BLOCK_START);
|
|
40
|
+
const end = existing.indexOf(BLOCK_END);
|
|
41
|
+
if (start === -1 || end === -1 || end < start) return `${block}\n\n${existing}`;
|
|
42
|
+
return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skillNames(): string[] {
|
|
46
|
+
return readdirSync(join(PACKAGE_ROOT, "skills"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function skillFiles(): PlannedFile[] {
|
|
50
|
+
return skillNames().map((name) => ({
|
|
51
|
+
path: join(".agents", "skills", name, "SKILL.md"),
|
|
52
|
+
contents: readAsset("skills", name, "SKILL.md"),
|
|
53
|
+
mode: "replace" as const,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Paths earlier versions generated for Claude Code and Cursor; sync deletes them.
|
|
58
|
+
function legacyPaths(): string[] {
|
|
59
|
+
return skillNames().flatMap((name) => [
|
|
60
|
+
join(".claude", "skills", name, "SKILL.md"),
|
|
61
|
+
join(".cursor", "skills", name, "SKILL.md"),
|
|
62
|
+
join(".cursor", "rules", `${name}.mdc`),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeLegacyFiles(appDir: string): string[] {
|
|
67
|
+
const removed = legacyPaths().filter((path) => existsSync(join(appDir, path)));
|
|
68
|
+
for (const path of removed) {
|
|
69
|
+
rmSync(join(appDir, path));
|
|
70
|
+
for (let dir = dirname(join(appDir, path)); dir !== appDir; dir = dirname(dir)) {
|
|
71
|
+
if (readdirSync(dir).length > 0) break;
|
|
72
|
+
rmdirSync(dir);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function plan(appDir: string): PlannedFile[] {
|
|
79
|
+
const agentsPath = join(appDir, "AGENTS.md");
|
|
80
|
+
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
|
|
81
|
+
return [
|
|
82
|
+
{ path: "AGENTS.md", contents: applyManagedBlock(existing), mode: "managed-block" },
|
|
83
|
+
...skillFiles(),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function staleFiles(appDir: string): string[] {
|
|
88
|
+
return plan(appDir)
|
|
89
|
+
.filter(({ path, contents }) => {
|
|
90
|
+
const target = join(appDir, path);
|
|
91
|
+
return !existsSync(target) || readFileSync(target, "utf8") !== contents;
|
|
92
|
+
})
|
|
93
|
+
.map(({ path }) => path)
|
|
94
|
+
.concat(legacyPaths().filter((path) => existsSync(join(appDir, path))));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function write(appDir: string): string[] {
|
|
98
|
+
const stale = new Set(staleFiles(appDir));
|
|
99
|
+
const written = plan(appDir).filter(({ path }) => stale.has(path));
|
|
100
|
+
for (const file of written) {
|
|
101
|
+
const target = join(appDir, file.path);
|
|
102
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
103
|
+
writeFileSync(target, file.contents);
|
|
104
|
+
}
|
|
105
|
+
return [...written.map(({ path }) => path), ...removeLegacyFiles(appDir)];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CLAUDE_FILES = ["CLAUDE.md", join(".claude", "CLAUDE.md"), "CLAUDE.local.md"];
|
|
109
|
+
const AGENTS_IMPORT = /^@AGENTS\.md\s*$/m;
|
|
110
|
+
|
|
111
|
+
// Claude Code reads AGENTS.md only when none of these files exist; otherwise it is ignored.
|
|
112
|
+
export function claudeFilesIgnoringAgents(appDir: string): string[] {
|
|
113
|
+
return CLAUDE_FILES.filter((file) => {
|
|
114
|
+
const path = join(appDir, file);
|
|
115
|
+
return existsSync(path) && !AGENTS_IMPORT.test(readFileSync(path, "utf8"));
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { basename, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
// npm and bun drop files named .gitignore from tarballs
|
|
5
|
+
// (https://docs.npmjs.com/cli/configuring-npm/package-json#files), so the template commits them renamed.
|
|
6
|
+
export const TARBALL_SAFE_NAMES: Record<string, string> = {
|
|
7
|
+
".gitignore": "_gitignore",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function restoreDotfileName(name: string): string {
|
|
11
|
+
return Object.entries(TARBALL_SAFE_NAMES).find(([, safe]) => safe === name)?.[0] ?? name;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A checkout's template folder collects dev output; the published tarball never has these (see "files" in package.json).
|
|
15
|
+
const UNSHIPPED_TOP_LEVEL = new Set([
|
|
16
|
+
"node_modules",
|
|
17
|
+
".next",
|
|
18
|
+
".turbo",
|
|
19
|
+
"coverage",
|
|
20
|
+
".vercel",
|
|
21
|
+
".DS_Store",
|
|
22
|
+
"next-env.d.ts",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function isShipped(relativePath: string): boolean {
|
|
26
|
+
const [topLevel] = relativePath.split(sep);
|
|
27
|
+
const name = basename(relativePath);
|
|
28
|
+
const isSecret = name.startsWith(".env") && name !== ".env.example";
|
|
29
|
+
return !(UNSHIPPED_TOP_LEVEL.has(topLevel) || name.endsWith(".tsbuildinfo") || isSecret);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function templateEntries(templateDir: string): string[] {
|
|
33
|
+
return readdirSync(templateDir).filter(isShipped);
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ib-feature
|
|
3
|
+
description: Scaffold or extend a feature slice in an inBeat app following the toolkit layout (actions, queries, server, components, schemas, tests). Use when adding a new feature, entity, page with data, or when the user mentions ib-feature or @ib-feature.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ib-feature
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
1. **Schema.** Table in `src/db/schema/<table>.ts` (uuid pk, `timestamp({ withTimezone: true })`,
|
|
11
|
+
snake_case). Export from `src/db/schema/index.ts`. `bun run db:generate`, then read the SQL.
|
|
12
|
+
Never apply to production unless the user says "apply migrations to prod".
|
|
13
|
+
2. **Entities.** Shared types, zod schemas, constants in `src/entities/<name>.ts` if more than
|
|
14
|
+
one feature needs them; otherwise `src/features/<f>/schemas.ts` and `types.ts`.
|
|
15
|
+
3. **Server.** `src/features/<f>/server/<verb>-<noun>-record.ts` with `import "server-only"`.
|
|
16
|
+
Pure DB/domain logic, takes validated input plus the viewer id, returns data or throws
|
|
17
|
+
`AppError`. Policies: `assert-can-<verb>.ts`.
|
|
18
|
+
4. **Queries.** `src/features/<f>/queries/get-<noun>.ts` / `list-<nouns>.ts`, `import "server-only"`,
|
|
19
|
+
run the app's own authorize check first when the app has auth, called from Server Components.
|
|
20
|
+
5. **Actions.** `src/features/<f>/actions/<verb>-<noun>.ts` with `"use server"` and `action()`.
|
|
21
|
+
One export per file. `revalidate` the pages that show the data.
|
|
22
|
+
6. **Routes** only for client polling, infinite lists, webhooks, presign: `route()` in
|
|
23
|
+
`src/app/api/<f>/.../route.ts`.
|
|
24
|
+
7. **UI.** Page in `src/app/.../page.tsx` (Server Component) renders feature components from
|
|
25
|
+
`src/features/<f>/components`. Client leaves call actions and handle `Result`.
|
|
26
|
+
8. **Tests.** Colocated `*.test.ts` for every `server/` command and policy and every `lib/` helper, including `src/shared/lib`.
|
|
27
|
+
9. **Check.** `bun run lint && bunx tsc --noEmit && bun run test && bun run lint:arch`.
|
|
28
|
+
|
|
29
|
+
## Naming
|
|
30
|
+
|
|
31
|
+
- Files kebab-case, one operation per file, named exports, no barrels (except `db/schema`).
|
|
32
|
+
- Actions verb+noun (`createClient`), queries `get*`/`list*`, server `*Record`,
|
|
33
|
+
policies `assertCan*`, hooks `use-*`.
|
|
34
|
+
- Indexes `<table>_<cols>_idx`, uniques `<table>_<cols>_unique`, FKs `*_user_id`.
|
|
35
|
+
|
|
36
|
+
## Don't
|
|
37
|
+
|
|
38
|
+
- Import another feature. Move the shared piece to `entities/` or `shared/`.
|
|
39
|
+
- Read data through a server action.
|
|
40
|
+
- Put `"use server"` on a file under `server/`.
|