@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.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/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/accessibility.mjs
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
// The accessibility check: axe-core (the standard WCAG rules engine) over the built HTML pages, in jsdom, before a
|
|
2
|
-
// version is completed. `deploy` sends the result with the version for the approvers' review checks, and
|
|
3
|
-
// `validate --build` prints it. It sees each page's HTML as built (what a static export prerenders), not what the app
|
|
4
|
-
// draws later in the browser, and no layout (so no color contrast): it catches missing labels, alt text, landmarks,
|
|
5
|
-
// heading order, lang, duplicate ids and the like.
|
|
6
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
7
|
-
import { join, relative, sep } from 'node:path';
|
|
8
|
-
|
|
9
|
-
/** At most this many pages are checked (the first ones by path), so a big build stays quick. */
|
|
10
|
-
export const MAX_PAGES = 10;
|
|
11
|
-
/** At most this many rules are reported, the worst first. */
|
|
12
|
-
export const MAX_RULES = 10;
|
|
13
|
-
const IMPACTS = ['critical', 'serious', 'moderate', 'minor'];
|
|
14
|
-
// Pages that are only there for the static host, not for users.
|
|
15
|
-
const SKIPPED = /(^|\/)(404|_not-found)(\/index)?\.html?$/i;
|
|
16
|
-
|
|
17
|
-
async function htmlFiles(folder) {
|
|
18
|
-
const found = [];
|
|
19
|
-
async function walk(current) {
|
|
20
|
-
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
21
|
-
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
22
|
-
const full = join(current, entry.name);
|
|
23
|
-
if (entry.isDirectory()) await walk(full);
|
|
24
|
-
else if (/\.html?$/i.test(entry.name)) found.push(relative(folder, full).split(sep).join('/'));
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
await walk(folder);
|
|
28
|
-
return found.filter((file) => !SKIPPED.test(file)).sort();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Runs axe over the built pages in `folder`: { status: 'checked', pages, critical, serious, moderate, minor, rules:
|
|
33
|
-
* [{ id, impact, count, help }] } (count: elements across pages), or { status: 'not_checked', reason }. Never throws.
|
|
34
|
-
*/
|
|
35
|
-
export async function checkAccessibility(folder, { maxPages = MAX_PAGES } = {}) {
|
|
36
|
-
let files;
|
|
37
|
-
try {
|
|
38
|
-
files = await htmlFiles(folder);
|
|
39
|
-
} catch {
|
|
40
|
-
return { status: 'not_checked', reason: 'the build folder could not be read' };
|
|
41
|
-
}
|
|
42
|
-
if (!files.length) return { status: 'not_checked', reason: 'no HTML pages in the build' };
|
|
43
|
-
|
|
44
|
-
let JSDOM, axeSource;
|
|
45
|
-
try {
|
|
46
|
-
({ JSDOM } = await import('jsdom'));
|
|
47
|
-
axeSource = (await import('axe-core')).default.source;
|
|
48
|
-
} catch {
|
|
49
|
-
return { status: 'not_checked', reason: 'axe-core or jsdom is not installed with the CLI' };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
53
|
-
const rules = new Map();
|
|
54
|
-
const pages = files.slice(0, maxPages);
|
|
55
|
-
try {
|
|
56
|
-
for (const file of pages) {
|
|
57
|
-
// The page's own scripts never run ('outside-only'): only axe, injected below.
|
|
58
|
-
const dom = new JSDOM(await readFile(join(folder, file), 'utf8'), { runScripts: 'outside-only', pretendToBeVisual: true });
|
|
59
|
-
try {
|
|
60
|
-
dom.window.eval(axeSource);
|
|
61
|
-
const result = await dom.window.axe.run(dom.window.document, {
|
|
62
|
-
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
|
|
63
|
-
// Needs layout, which jsdom doesn't have.
|
|
64
|
-
rules: { 'color-contrast': { enabled: false } },
|
|
65
|
-
resultTypes: ['violations'],
|
|
66
|
-
});
|
|
67
|
-
for (const violation of result.violations) {
|
|
68
|
-
const impact = IMPACTS.includes(violation.impact) ? violation.impact : 'minor';
|
|
69
|
-
counts[impact] += violation.nodes.length;
|
|
70
|
-
const rule = rules.get(violation.id) ?? { id: violation.id, impact, count: 0, help: String(violation.help ?? '').slice(0, 200) };
|
|
71
|
-
rule.count += violation.nodes.length;
|
|
72
|
-
rules.set(violation.id, rule);
|
|
73
|
-
}
|
|
74
|
-
} finally {
|
|
75
|
-
dom.window.close();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
} catch (error) {
|
|
79
|
-
return { status: 'not_checked', reason: `axe failed: ${String(error?.message ?? error).slice(0, 120)}` };
|
|
80
|
-
}
|
|
81
|
-
const worst = [...rules.values()]
|
|
82
|
-
.sort((a, b) => IMPACTS.indexOf(a.impact) - IMPACTS.indexOf(b.impact) || b.count - a.count)
|
|
83
|
-
.slice(0, MAX_RULES);
|
|
84
|
-
return { status: 'checked', pages: pages.length, ...counts, rules: worst };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** One line for the terminal, e.g. "2 serious, 1 moderate in 3 pages (label, image-alt)". */
|
|
88
|
-
export function describeAccessibility(report) {
|
|
89
|
-
if (report.status !== 'checked') return `not checked: ${report.reason}`;
|
|
90
|
-
const found = IMPACTS.filter((impact) => report[impact]).map((impact) => `${report[impact]} ${impact}`);
|
|
91
|
-
const pages = `${report.pages} page${report.pages === 1 ? '' : 's'}`;
|
|
92
|
-
if (!found.length) return `no issues found in ${pages} (axe)`;
|
|
93
|
-
return `${found.join(', ')} in ${pages} (${report.rules.map((r) => r.id).join(', ')})`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Findings worth fixing before review: critical and serious issues, one line each. */
|
|
97
|
-
export function accessibilityWarnings(report) {
|
|
98
|
-
if (report.status !== 'checked' || report.critical + report.serious === 0) return [];
|
|
99
|
-
const worst = report.rules.filter((r) => r.impact === 'critical' || r.impact === 'serious');
|
|
100
|
-
return [
|
|
101
|
-
`Accessibility: ${describeAccessibility(report)}. ${report.critical ? 'Critical issues fail the review check (approvers can only force approve). ' : ''}Fix: ${worst
|
|
102
|
-
.map((r) => `${r.id} (${r.count}): ${r.help}`)
|
|
103
|
-
.join('; ')}.`,
|
|
104
|
-
];
|
|
105
|
-
}
|
|
1
|
+
// The accessibility check: axe-core (the standard WCAG rules engine) over the built HTML pages, in jsdom, before a
|
|
2
|
+
// version is completed. `deploy` sends the result with the version for the approvers' review checks, and
|
|
3
|
+
// `validate --build` prints it. It sees each page's HTML as built (what a static export prerenders), not what the app
|
|
4
|
+
// draws later in the browser, and no layout (so no color contrast): it catches missing labels, alt text, landmarks,
|
|
5
|
+
// heading order, lang, duplicate ids and the like.
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import { join, relative, sep } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** At most this many pages are checked (the first ones by path), so a big build stays quick. */
|
|
10
|
+
export const MAX_PAGES = 10;
|
|
11
|
+
/** At most this many rules are reported, the worst first. */
|
|
12
|
+
export const MAX_RULES = 10;
|
|
13
|
+
const IMPACTS = ['critical', 'serious', 'moderate', 'minor'];
|
|
14
|
+
// Pages that are only there for the static host, not for users.
|
|
15
|
+
const SKIPPED = /(^|\/)(404|_not-found)(\/index)?\.html?$/i;
|
|
16
|
+
|
|
17
|
+
async function htmlFiles(folder) {
|
|
18
|
+
const found = [];
|
|
19
|
+
async function walk(current) {
|
|
20
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
21
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
22
|
+
const full = join(current, entry.name);
|
|
23
|
+
if (entry.isDirectory()) await walk(full);
|
|
24
|
+
else if (/\.html?$/i.test(entry.name)) found.push(relative(folder, full).split(sep).join('/'));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await walk(folder);
|
|
28
|
+
return found.filter((file) => !SKIPPED.test(file)).sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs axe over the built pages in `folder`: { status: 'checked', pages, critical, serious, moderate, minor, rules:
|
|
33
|
+
* [{ id, impact, count, help }] } (count: elements across pages), or { status: 'not_checked', reason }. Never throws.
|
|
34
|
+
*/
|
|
35
|
+
export async function checkAccessibility(folder, { maxPages = MAX_PAGES } = {}) {
|
|
36
|
+
let files;
|
|
37
|
+
try {
|
|
38
|
+
files = await htmlFiles(folder);
|
|
39
|
+
} catch {
|
|
40
|
+
return { status: 'not_checked', reason: 'the build folder could not be read' };
|
|
41
|
+
}
|
|
42
|
+
if (!files.length) return { status: 'not_checked', reason: 'no HTML pages in the build' };
|
|
43
|
+
|
|
44
|
+
let JSDOM, axeSource;
|
|
45
|
+
try {
|
|
46
|
+
({ JSDOM } = await import('jsdom'));
|
|
47
|
+
axeSource = (await import('axe-core')).default.source;
|
|
48
|
+
} catch {
|
|
49
|
+
return { status: 'not_checked', reason: 'axe-core or jsdom is not installed with the CLI' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
53
|
+
const rules = new Map();
|
|
54
|
+
const pages = files.slice(0, maxPages);
|
|
55
|
+
try {
|
|
56
|
+
for (const file of pages) {
|
|
57
|
+
// The page's own scripts never run ('outside-only'): only axe, injected below.
|
|
58
|
+
const dom = new JSDOM(await readFile(join(folder, file), 'utf8'), { runScripts: 'outside-only', pretendToBeVisual: true });
|
|
59
|
+
try {
|
|
60
|
+
dom.window.eval(axeSource);
|
|
61
|
+
const result = await dom.window.axe.run(dom.window.document, {
|
|
62
|
+
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
|
|
63
|
+
// Needs layout, which jsdom doesn't have.
|
|
64
|
+
rules: { 'color-contrast': { enabled: false } },
|
|
65
|
+
resultTypes: ['violations'],
|
|
66
|
+
});
|
|
67
|
+
for (const violation of result.violations) {
|
|
68
|
+
const impact = IMPACTS.includes(violation.impact) ? violation.impact : 'minor';
|
|
69
|
+
counts[impact] += violation.nodes.length;
|
|
70
|
+
const rule = rules.get(violation.id) ?? { id: violation.id, impact, count: 0, help: String(violation.help ?? '').slice(0, 200) };
|
|
71
|
+
rule.count += violation.nodes.length;
|
|
72
|
+
rules.set(violation.id, rule);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
dom.window.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { status: 'not_checked', reason: `axe failed: ${String(error?.message ?? error).slice(0, 120)}` };
|
|
80
|
+
}
|
|
81
|
+
const worst = [...rules.values()]
|
|
82
|
+
.sort((a, b) => IMPACTS.indexOf(a.impact) - IMPACTS.indexOf(b.impact) || b.count - a.count)
|
|
83
|
+
.slice(0, MAX_RULES);
|
|
84
|
+
return { status: 'checked', pages: pages.length, ...counts, rules: worst };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One line for the terminal, e.g. "2 serious, 1 moderate in 3 pages (label, image-alt)". */
|
|
88
|
+
export function describeAccessibility(report) {
|
|
89
|
+
if (report.status !== 'checked') return `not checked: ${report.reason}`;
|
|
90
|
+
const found = IMPACTS.filter((impact) => report[impact]).map((impact) => `${report[impact]} ${impact}`);
|
|
91
|
+
const pages = `${report.pages} page${report.pages === 1 ? '' : 's'}`;
|
|
92
|
+
if (!found.length) return `no issues found in ${pages} (axe)`;
|
|
93
|
+
return `${found.join(', ')} in ${pages} (${report.rules.map((r) => r.id).join(', ')})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Findings worth fixing before review: critical and serious issues, one line each. */
|
|
97
|
+
export function accessibilityWarnings(report) {
|
|
98
|
+
if (report.status !== 'checked' || report.critical + report.serious === 0) return [];
|
|
99
|
+
const worst = report.rules.filter((r) => r.impact === 'critical' || r.impact === 'serious');
|
|
100
|
+
return [
|
|
101
|
+
`Accessibility: ${describeAccessibility(report)}. ${report.critical ? 'Critical issues fail the review check (approvers can only force approve). ' : ''}Fix: ${worst
|
|
102
|
+
.map((r) => `${r.id} (${r.count}): ${r.help}`)
|
|
103
|
+
.join('; ')}.`,
|
|
104
|
+
];
|
|
105
|
+
}
|
package/src/agents.mjs
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
// Instructions for AI coding assistants, written into each app: AGENTS.md (read by Copilot, Cursor, Codex and others),
|
|
2
|
-
// a CLAUDE.md that imports it (Claude Code), and the CLI's MCP server (`hawk-app mcp`) in .mcp.json (Claude Code) and
|
|
3
|
-
// .vscode/mcp.json (VS Code / Copilot). The CLI owns the part of AGENTS.md between its markers and refreshes
|
|
4
|
-
// it with `hawk-app upgrade`; anything outside the markers is the developer's. Sources: ../agents/AGENTS.md plus one
|
|
5
|
-
// project section per template (../agents/project/), or custom.md for an app set up with `hawk-app init`.
|
|
6
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
|
|
11
|
-
const AGENTS_DIR = fileURLToPath(new URL('../agents', import.meta.url));
|
|
12
|
-
const START = '<!-- hawk-app:start: written by the Hawk Apps CLI and refreshed by `npx hawk-app upgrade`. Add your own notes outside these markers. -->';
|
|
13
|
-
const END = '<!-- hawk-app:end -->';
|
|
14
|
-
const BLOCK = /<!-- hawk-app:start[^]*?<!-- hawk-app:end -->/;
|
|
15
|
-
const CLAUDE_IMPORT = '@AGENTS.md';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The MCP server (`hawk-app mcp`) in each assistant's settings file, started with node from the app's own copy of the
|
|
19
|
-
* CLI (a dev dependency): no npx, which needs a shell wrapper on Windows. Claude Code starts it in the project folder.
|
|
20
|
-
*/
|
|
21
|
-
const CLI_BIN = 'node_modules/@bridgeline-digital/hawk-app-cli/bin/hawk-app.mjs';
|
|
22
|
-
const MCP_CONFIGS = [
|
|
23
|
-
{ file: '.mcp.json', key: 'mcpServers', entry: { command: 'node', args: [CLI_BIN, 'mcp'] } },
|
|
24
|
-
{
|
|
25
|
-
file: '.vscode/mcp.json',
|
|
26
|
-
key: 'servers',
|
|
27
|
-
entry: { type: 'stdio', command: 'node', args: [`\${workspaceFolder}/${CLI_BIN}`, 'mcp'], cwd: '${workspaceFolder}' },
|
|
28
|
-
},
|
|
29
|
-
];
|
|
30
|
-
const MCP_SERVER = 'hawk-apps';
|
|
31
|
-
|
|
32
|
-
/** An assistant's MCP settings with the hawk-apps server: created, added to the developer's own, or left as is. */
|
|
33
|
-
function planMcpConfig(dir, { file, key, entry }) {
|
|
34
|
-
const full = join(dir, file);
|
|
35
|
-
if (!existsSync(full)) return { file, state: 'create', content: JSON.stringify({ [key]: { [MCP_SERVER]: entry } }, null, 2) + '\n' };
|
|
36
|
-
const text = readFileSync(full, 'utf8');
|
|
37
|
-
let config;
|
|
38
|
-
try {
|
|
39
|
-
config = JSON.parse(text);
|
|
40
|
-
} catch {
|
|
41
|
-
// JSON with comments (VS Code allows them) or broken: not ours to rewrite.
|
|
42
|
-
return { file, state: 'skipped', content: text };
|
|
43
|
-
}
|
|
44
|
-
if (config?.[key]?.[MCP_SERVER]) return { file, state: 'current', content: text };
|
|
45
|
-
const updated = { ...config, [key]: { ...config?.[key], [MCP_SERVER]: entry } };
|
|
46
|
-
return { file, state: 'append', content: JSON.stringify(updated, null, 2) + (text.includes('\r\n') ? '\r\n' : '\n') };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The project section each template uses; any other app gets custom.md. */
|
|
50
|
-
const PROJECT_SECTIONS = { react: 'vite', preact: 'vite', next: 'next', 'next-mui': 'next-mui', vanilla: 'vanilla' };
|
|
51
|
-
|
|
52
|
-
/** The CLI's block of AGENTS.md for an app, markers included (LF line endings). */
|
|
53
|
-
export function renderAgents({ slug, name, template }) {
|
|
54
|
-
const read = (file) => readFileSync(join(AGENTS_DIR, file), 'utf8').replace(/\r\n/g, '\n').trim();
|
|
55
|
-
const project = read(`project/${PROJECT_SECTIONS[template] ?? 'custom'}.md`);
|
|
56
|
-
const values = { __SLUG__: slug || '<slug>', __NAME__: name || slug || 'This app', __TEMPLATE__: template ?? '' };
|
|
57
|
-
const body = read('AGENTS.md')
|
|
58
|
-
.replace('__PROJECT__', () => project)
|
|
59
|
-
.replace(/__SLUG__|__NAME__|__TEMPLATE__/g, (m) => values[m]);
|
|
60
|
-
return `${START}\n${body}\n${END}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* What writing the assistant files would do: [{ file, state: 'create' | 'update' | 'append' | 'current', content }].
|
|
65
|
-
* AGENTS.md: created, its block replaced, or the block appended to the developer's own file. CLAUDE.md: created, or
|
|
66
|
-
* the import appended when it doesn't have it.
|
|
67
|
-
*/
|
|
68
|
-
export function planAgentFiles(dir, app) {
|
|
69
|
-
const block = renderAgents(app);
|
|
70
|
-
const files = [];
|
|
71
|
-
|
|
72
|
-
const agentsFile = join(dir, 'AGENTS.md');
|
|
73
|
-
if (!existsSync(agentsFile)) {
|
|
74
|
-
files.push({ file: 'AGENTS.md', state: 'create', content: block + '\n' });
|
|
75
|
-
} else {
|
|
76
|
-
const text = readFileSync(agentsFile, 'utf8');
|
|
77
|
-
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
78
|
-
const ours = block.replace(/\n/g, eol);
|
|
79
|
-
if (BLOCK.test(text)) {
|
|
80
|
-
const updated = text.replace(BLOCK, () => ours);
|
|
81
|
-
files.push({ file: 'AGENTS.md', state: updated === text ? 'current' : 'update', content: updated });
|
|
82
|
-
} else {
|
|
83
|
-
files.push({ file: 'AGENTS.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${ours}${eol}` });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
for (const config of MCP_CONFIGS) files.push(planMcpConfig(dir, config));
|
|
88
|
-
|
|
89
|
-
const claudeFile = join(dir, 'CLAUDE.md');
|
|
90
|
-
if (!existsSync(claudeFile)) {
|
|
91
|
-
files.push({ file: 'CLAUDE.md', state: 'create', content: `${CLAUDE_IMPORT}\n` });
|
|
92
|
-
} else {
|
|
93
|
-
const text = readFileSync(claudeFile, 'utf8');
|
|
94
|
-
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
95
|
-
files.push(
|
|
96
|
-
/^@AGENTS\.md\s*$/m.test(text)
|
|
97
|
-
? { file: 'CLAUDE.md', state: 'current', content: text }
|
|
98
|
-
: { file: 'CLAUDE.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${CLAUDE_IMPORT}${eol}` },
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return files;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Writes what planAgentFiles found out of date; returns the files written. */
|
|
105
|
-
/** Whether planAgentFiles found the file out of date (a file it leaves alone isn't). */
|
|
106
|
-
export const isAgentChange = (f) => f.state !== 'current' && f.state !== 'skipped';
|
|
107
|
-
|
|
108
|
-
export async function writeAgentFiles(dir, plan) {
|
|
109
|
-
const changed = plan.filter(isAgentChange);
|
|
110
|
-
for (const f of changed) {
|
|
111
|
-
await mkdir(dirname(join(dir, f.file)), { recursive: true });
|
|
112
|
-
await writeFile(join(dir, f.file), f.content);
|
|
113
|
-
}
|
|
114
|
-
return changed.map((f) => f.file);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** One line per file that would change, for create, init and upgrade. */
|
|
118
|
-
export function describeAgentFile({ file, state }) {
|
|
119
|
-
const mcp = file.endsWith('mcp.json');
|
|
120
|
-
switch (state) {
|
|
121
|
-
case 'create':
|
|
122
|
-
if (mcp) return `${file}: added (the hawk-app MCP server for ${file === '.mcp.json' ? 'Claude Code' : 'VS Code'}).`;
|
|
123
|
-
return file === 'AGENTS.md' ? 'AGENTS.md: added (instructions for AI coding assistants).' : `${file}: added (points Claude Code at AGENTS.md).`;
|
|
124
|
-
case 'update':
|
|
125
|
-
return `${file}: the CLI's part is refreshed (your notes outside its markers are kept).`;
|
|
126
|
-
case 'append':
|
|
127
|
-
if (mcp) return `${file}: the hawk-apps MCP server is added to your servers.`;
|
|
128
|
-
return file === 'AGENTS.md' ? "AGENTS.md: the CLI's part is added after your own." : `${file}: ${CLAUDE_IMPORT} is added, so Claude Code reads AGENTS.md.`;
|
|
129
|
-
case 'skipped':
|
|
130
|
-
return `${file}: not plain JSON (comments?), so left alone. Add the "${MCP_SERVER}" server yourself: see the CLI README.`;
|
|
131
|
-
default:
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Plans and writes the assistant files for a new app (create, init), logging each one. */
|
|
137
|
-
export async function addAgentFiles(dir, app, log) {
|
|
138
|
-
const plan = planAgentFiles(dir, app);
|
|
139
|
-
await writeAgentFiles(dir, plan);
|
|
140
|
-
for (const f of plan) {
|
|
141
|
-
const line = describeAgentFile(f);
|
|
142
|
-
if (line) log(` ${line}`);
|
|
143
|
-
}
|
|
144
|
-
return plan.filter(isAgentChange).map((f) => f.file);
|
|
145
|
-
}
|
|
1
|
+
// Instructions for AI coding assistants, written into each app: AGENTS.md (read by Copilot, Cursor, Codex and others),
|
|
2
|
+
// a CLAUDE.md that imports it (Claude Code), and the CLI's MCP server (`hawk-app mcp`) in .mcp.json (Claude Code) and
|
|
3
|
+
// .vscode/mcp.json (VS Code / Copilot). The CLI owns the part of AGENTS.md between its markers and refreshes
|
|
4
|
+
// it with `hawk-app upgrade`; anything outside the markers is the developer's. Sources: ../agents/AGENTS.md plus one
|
|
5
|
+
// project section per template (../agents/project/), or custom.md for an app set up with `hawk-app init`.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const AGENTS_DIR = fileURLToPath(new URL('../agents', import.meta.url));
|
|
12
|
+
const START = '<!-- hawk-app:start: written by the Hawk Apps CLI and refreshed by `npx hawk-app upgrade`. Add your own notes outside these markers. -->';
|
|
13
|
+
const END = '<!-- hawk-app:end -->';
|
|
14
|
+
const BLOCK = /<!-- hawk-app:start[^]*?<!-- hawk-app:end -->/;
|
|
15
|
+
const CLAUDE_IMPORT = '@AGENTS.md';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The MCP server (`hawk-app mcp`) in each assistant's settings file, started with node from the app's own copy of the
|
|
19
|
+
* CLI (a dev dependency): no npx, which needs a shell wrapper on Windows. Claude Code starts it in the project folder.
|
|
20
|
+
*/
|
|
21
|
+
const CLI_BIN = 'node_modules/@bridgeline-digital/hawk-app-cli/bin/hawk-app.mjs';
|
|
22
|
+
const MCP_CONFIGS = [
|
|
23
|
+
{ file: '.mcp.json', key: 'mcpServers', entry: { command: 'node', args: [CLI_BIN, 'mcp'] } },
|
|
24
|
+
{
|
|
25
|
+
file: '.vscode/mcp.json',
|
|
26
|
+
key: 'servers',
|
|
27
|
+
entry: { type: 'stdio', command: 'node', args: [`\${workspaceFolder}/${CLI_BIN}`, 'mcp'], cwd: '${workspaceFolder}' },
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
const MCP_SERVER = 'hawk-apps';
|
|
31
|
+
|
|
32
|
+
/** An assistant's MCP settings with the hawk-apps server: created, added to the developer's own, or left as is. */
|
|
33
|
+
function planMcpConfig(dir, { file, key, entry }) {
|
|
34
|
+
const full = join(dir, file);
|
|
35
|
+
if (!existsSync(full)) return { file, state: 'create', content: JSON.stringify({ [key]: { [MCP_SERVER]: entry } }, null, 2) + '\n' };
|
|
36
|
+
const text = readFileSync(full, 'utf8');
|
|
37
|
+
let config;
|
|
38
|
+
try {
|
|
39
|
+
config = JSON.parse(text);
|
|
40
|
+
} catch {
|
|
41
|
+
// JSON with comments (VS Code allows them) or broken: not ours to rewrite.
|
|
42
|
+
return { file, state: 'skipped', content: text };
|
|
43
|
+
}
|
|
44
|
+
if (config?.[key]?.[MCP_SERVER]) return { file, state: 'current', content: text };
|
|
45
|
+
const updated = { ...config, [key]: { ...config?.[key], [MCP_SERVER]: entry } };
|
|
46
|
+
return { file, state: 'append', content: JSON.stringify(updated, null, 2) + (text.includes('\r\n') ? '\r\n' : '\n') };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The project section each template uses; any other app gets custom.md. */
|
|
50
|
+
const PROJECT_SECTIONS = { react: 'vite', preact: 'vite', next: 'next', 'next-mui': 'next-mui', vanilla: 'vanilla' };
|
|
51
|
+
|
|
52
|
+
/** The CLI's block of AGENTS.md for an app, markers included (LF line endings). */
|
|
53
|
+
export function renderAgents({ slug, name, template }) {
|
|
54
|
+
const read = (file) => readFileSync(join(AGENTS_DIR, file), 'utf8').replace(/\r\n/g, '\n').trim();
|
|
55
|
+
const project = read(`project/${PROJECT_SECTIONS[template] ?? 'custom'}.md`);
|
|
56
|
+
const values = { __SLUG__: slug || '<slug>', __NAME__: name || slug || 'This app', __TEMPLATE__: template ?? '' };
|
|
57
|
+
const body = read('AGENTS.md')
|
|
58
|
+
.replace('__PROJECT__', () => project)
|
|
59
|
+
.replace(/__SLUG__|__NAME__|__TEMPLATE__/g, (m) => values[m]);
|
|
60
|
+
return `${START}\n${body}\n${END}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What writing the assistant files would do: [{ file, state: 'create' | 'update' | 'append' | 'current', content }].
|
|
65
|
+
* AGENTS.md: created, its block replaced, or the block appended to the developer's own file. CLAUDE.md: created, or
|
|
66
|
+
* the import appended when it doesn't have it.
|
|
67
|
+
*/
|
|
68
|
+
export function planAgentFiles(dir, app) {
|
|
69
|
+
const block = renderAgents(app);
|
|
70
|
+
const files = [];
|
|
71
|
+
|
|
72
|
+
const agentsFile = join(dir, 'AGENTS.md');
|
|
73
|
+
if (!existsSync(agentsFile)) {
|
|
74
|
+
files.push({ file: 'AGENTS.md', state: 'create', content: block + '\n' });
|
|
75
|
+
} else {
|
|
76
|
+
const text = readFileSync(agentsFile, 'utf8');
|
|
77
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
78
|
+
const ours = block.replace(/\n/g, eol);
|
|
79
|
+
if (BLOCK.test(text)) {
|
|
80
|
+
const updated = text.replace(BLOCK, () => ours);
|
|
81
|
+
files.push({ file: 'AGENTS.md', state: updated === text ? 'current' : 'update', content: updated });
|
|
82
|
+
} else {
|
|
83
|
+
files.push({ file: 'AGENTS.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${ours}${eol}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const config of MCP_CONFIGS) files.push(planMcpConfig(dir, config));
|
|
88
|
+
|
|
89
|
+
const claudeFile = join(dir, 'CLAUDE.md');
|
|
90
|
+
if (!existsSync(claudeFile)) {
|
|
91
|
+
files.push({ file: 'CLAUDE.md', state: 'create', content: `${CLAUDE_IMPORT}\n` });
|
|
92
|
+
} else {
|
|
93
|
+
const text = readFileSync(claudeFile, 'utf8');
|
|
94
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
95
|
+
files.push(
|
|
96
|
+
/^@AGENTS\.md\s*$/m.test(text)
|
|
97
|
+
? { file: 'CLAUDE.md', state: 'current', content: text }
|
|
98
|
+
: { file: 'CLAUDE.md', state: 'append', content: `${text.replace(/\s*$/, '')}${eol}${eol}${CLAUDE_IMPORT}${eol}` },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return files;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Writes what planAgentFiles found out of date; returns the files written. */
|
|
105
|
+
/** Whether planAgentFiles found the file out of date (a file it leaves alone isn't). */
|
|
106
|
+
export const isAgentChange = (f) => f.state !== 'current' && f.state !== 'skipped';
|
|
107
|
+
|
|
108
|
+
export async function writeAgentFiles(dir, plan) {
|
|
109
|
+
const changed = plan.filter(isAgentChange);
|
|
110
|
+
for (const f of changed) {
|
|
111
|
+
await mkdir(dirname(join(dir, f.file)), { recursive: true });
|
|
112
|
+
await writeFile(join(dir, f.file), f.content);
|
|
113
|
+
}
|
|
114
|
+
return changed.map((f) => f.file);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One line per file that would change, for create, init and upgrade. */
|
|
118
|
+
export function describeAgentFile({ file, state }) {
|
|
119
|
+
const mcp = file.endsWith('mcp.json');
|
|
120
|
+
switch (state) {
|
|
121
|
+
case 'create':
|
|
122
|
+
if (mcp) return `${file}: added (the hawk-app MCP server for ${file === '.mcp.json' ? 'Claude Code' : 'VS Code'}).`;
|
|
123
|
+
return file === 'AGENTS.md' ? 'AGENTS.md: added (instructions for AI coding assistants).' : `${file}: added (points Claude Code at AGENTS.md).`;
|
|
124
|
+
case 'update':
|
|
125
|
+
return `${file}: the CLI's part is refreshed (your notes outside its markers are kept).`;
|
|
126
|
+
case 'append':
|
|
127
|
+
if (mcp) return `${file}: the hawk-apps MCP server is added to your servers.`;
|
|
128
|
+
return file === 'AGENTS.md' ? "AGENTS.md: the CLI's part is added after your own." : `${file}: ${CLAUDE_IMPORT} is added, so Claude Code reads AGENTS.md.`;
|
|
129
|
+
case 'skipped':
|
|
130
|
+
return `${file}: not plain JSON (comments?), so left alone. Add the "${MCP_SERVER}" server yourself: see the CLI README.`;
|
|
131
|
+
default:
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Plans and writes the assistant files for a new app (create, init), logging each one. */
|
|
137
|
+
export async function addAgentFiles(dir, app, log) {
|
|
138
|
+
const plan = planAgentFiles(dir, app);
|
|
139
|
+
await writeAgentFiles(dir, plan);
|
|
140
|
+
for (const f of plan) {
|
|
141
|
+
const line = describeAgentFile(f);
|
|
142
|
+
if (line) log(` ${line}`);
|
|
143
|
+
}
|
|
144
|
+
return plan.filter(isAgentChange).map((f) => f.file);
|
|
145
|
+
}
|
package/src/aliases.mjs
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
/** Same rules as the platform's alias names: lowercase letters, digits and single dashes, at most 40. */
|
|
2
|
-
export const ALIAS_NAME = /^(?=.{1,40}$)[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
-
|
|
4
|
-
/** The built-in alias: the version released to everyone, for every client without another alias. */
|
|
5
|
-
export const DEFAULT_ALIAS = 'default';
|
|
6
|
-
|
|
7
|
-
/** Names no alias may take (old names for the default alias and for statuses, which come from the version). */
|
|
8
|
-
const RESERVED = ['latest', 'live', 'draft'];
|
|
9
|
-
|
|
10
|
-
/** The alias name, checked; `allowDefault` for putting a client back on the default. */
|
|
11
|
-
export function checkAlias(name, { allowDefault = false, flag = '--alias' } = {}) {
|
|
12
|
-
if (typeof name !== 'string' || !name) throw new Error(`${flag} needs an alias name, e.g. ${flag} staging.`);
|
|
13
|
-
if (name === DEFAULT_ALIAS && allowDefault) return name;
|
|
14
|
-
if (name === DEFAULT_ALIAS || RESERVED.includes(name)) throw new Error(`"${name}" is reserved: pick another alias name, e.g. staging or review.`);
|
|
15
|
-
if (!ALIAS_NAME.test(name)) throw new Error(`"${name}" isn't a valid alias: lowercase letters, digits and single dashes (at most 40), e.g. staging.`);
|
|
16
|
-
return name;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** "pilot v8 (3 clients)", "staging v9 (draft, 1 client)"; the default's count is null when the app is for every client. */
|
|
20
|
-
export function describeAlias(alias) {
|
|
21
|
-
const clients = alias.clients === null || alias.clients === undefined ? 'all other clients' : `${alias.clients} client${alias.clients === 1 ? '' : 's'}`;
|
|
22
|
-
const status = alias.status && alias.status !== 'approved' ? `${alias.status}: Bridgeline staff only, ` : '';
|
|
23
|
-
return `${alias.name} v${alias.version ?? '?'} (${status}${clients})`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** " [on pilot]", " [on staging · draft: Bridgeline staff only]" after a client in lists; nothing on the default. */
|
|
27
|
-
export function describeClientAlias(c) {
|
|
28
|
-
if (!c.alias) return '';
|
|
29
|
-
return c.aliasStatus && c.aliasStatus !== 'approved' ? ` [on ${c.alias} · ${c.aliasStatus}: Bridgeline staff only]` : ` [on ${c.alias}]`;
|
|
30
|
-
}
|
|
1
|
+
/** Same rules as the platform's alias names: lowercase letters, digits and single dashes, at most 40. */
|
|
2
|
+
export const ALIAS_NAME = /^(?=.{1,40}$)[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
+
|
|
4
|
+
/** The built-in alias: the version released to everyone, for every client without another alias. */
|
|
5
|
+
export const DEFAULT_ALIAS = 'default';
|
|
6
|
+
|
|
7
|
+
/** Names no alias may take (old names for the default alias and for statuses, which come from the version). */
|
|
8
|
+
const RESERVED = ['latest', 'live', 'draft'];
|
|
9
|
+
|
|
10
|
+
/** The alias name, checked; `allowDefault` for putting a client back on the default. */
|
|
11
|
+
export function checkAlias(name, { allowDefault = false, flag = '--alias' } = {}) {
|
|
12
|
+
if (typeof name !== 'string' || !name) throw new Error(`${flag} needs an alias name, e.g. ${flag} staging.`);
|
|
13
|
+
if (name === DEFAULT_ALIAS && allowDefault) return name;
|
|
14
|
+
if (name === DEFAULT_ALIAS || RESERVED.includes(name)) throw new Error(`"${name}" is reserved: pick another alias name, e.g. staging or review.`);
|
|
15
|
+
if (!ALIAS_NAME.test(name)) throw new Error(`"${name}" isn't a valid alias: lowercase letters, digits and single dashes (at most 40), e.g. staging.`);
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "pilot v8 (3 clients)", "staging v9 (draft, 1 client)"; the default's count is null when the app is for every client. */
|
|
20
|
+
export function describeAlias(alias) {
|
|
21
|
+
const clients = alias.clients === null || alias.clients === undefined ? 'all other clients' : `${alias.clients} client${alias.clients === 1 ? '' : 's'}`;
|
|
22
|
+
const status = alias.status && alias.status !== 'approved' ? `${alias.status}: Bridgeline staff only, ` : '';
|
|
23
|
+
return `${alias.name} v${alias.version ?? '?'} (${status}${clients})`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** " [on pilot]", " [on staging · draft: Bridgeline staff only]" after a client in lists; nothing on the default. */
|
|
27
|
+
export function describeClientAlias(c) {
|
|
28
|
+
if (!c.alias) return '';
|
|
29
|
+
return c.aliasStatus && c.aliasStatus !== 'approved' ? ` [on ${c.alias} · ${c.aliasStatus}: Bridgeline staff only]` : ` [on ${c.alias}]`;
|
|
30
|
+
}
|