Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions create-bestax/e2e/utils/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ export async function scaffoldApp(config: ScaffoldConfig): Promise<string> {
stdio: 'inherit',
});

// -y implies the AI-skills opt-in, so every real scaffold must contain the
// browser-preview manifest (#337) — fail loudly if the write path regresses.
const launchJsonPath = path.join(outputDir, '.claude', 'launch.json');
if (!(await fs.pathExists(launchJsonPath))) {
throw new Error(`Expected scaffold to contain ${launchJsonPath}`);
}

// Install dependencies in the scaffolded app with a fresh pnpm resolve.
// --ignore-workspace: the app may live inside the monorepo tree, so treat it
// as a standalone project rather than a workspace member.
Expand Down
21 changes: 21 additions & 0 deletions create-bestax/src/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const {
PROJECT_NAME_REGEX,
MESSAGES,
PROMPTS,
LAUNCH_JSON,
} = await import('../constants.js');

describe('constants', () => {
Expand Down Expand Up @@ -206,6 +207,26 @@ describe('constants', () => {
});
});

describe('LAUNCH_JSON', () => {
it('is valid JSON with a single dev configuration', () => {
const launch = JSON.parse(LAUNCH_JSON);
expect(launch.version).toBe('0.0.1');
expect(launch.configurations).toHaveLength(1);
expect(launch.configurations[0].name).toBe('dev');
});

it('uses package-manager-neutral npm and pins the Vite port with --strictPort', () => {
const config = JSON.parse(LAUNCH_JSON).configurations[0];
expect(config.runtimeExecutable).toBe('npm');
expect(config.runtimeArgs).toEqual(['run', 'dev', '--', '--strictPort']);
expect(config.port).toBe(5173);
});

it('is mentioned in the skills success message', () => {
expect(MESSAGES.SKILLS_ADDED).toContain('launch.json');
});
});

describe('DEFAULT_PROJECT_NAME', () => {
it('should be a valid project name', () => {
expect(DEFAULT_PROJECT_NAME).toBeDefined();
Expand Down
80 changes: 80 additions & 0 deletions create-bestax/src/__tests__/project-creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,37 @@ describe('ProjectCreator', () => {
expect(written).not.toContain('classPrefix');
});

it('writes .claude/launch.json for the browser preview alongside the skills', async () => {
(
fileSystem.checkDirectoryExists as jest.MockedFunction<
typeof fileSystem.checkDirectoryExists
>
).mockResolvedValue(true);

await projectCreator.setupSkills('/target/path', 'my-app', {
bulmaFlavor: 'complete',
iconLibrary: 'none',
});

const written = (
fs.default.writeFile as unknown as jest.Mock
).mock.calls.find(([p]) => String(p).includes('launch.json'))?.[1];
expect(written).toBeDefined();
const launch = JSON.parse(String(written));
expect(launch.configurations).toHaveLength(1);
expect(launch.configurations[0]).toEqual({
name: 'dev',
runtimeExecutable: 'npm',
runtimeArgs: ['run', 'dev', '--', '--strictPort'],
port: 5173,
});
// the scaffolded CLAUDE.md points agents at the launch config
const claudeMd = (
fs.default.writeFile as unknown as jest.Mock
).mock.calls.find(([p]) => String(p).includes('CLAUDE.md'))?.[1];
expect(claudeMd).toContain('.claude/launch.json');
});

it('documents the prefix caveat and icon library for a prefixed scaffold', async () => {
(
fileSystem.checkDirectoryExists as jest.MockedFunction<
Expand Down Expand Up @@ -933,6 +964,55 @@ describe('ProjectCreator', () => {
expect(fileSystem.updatePackageJson).toHaveBeenCalled();
});

it('writes launch.json when the skills opt-in is accepted', async () => {
// target dir does not exist; the bundled skills dir does
(
fileSystem.checkDirectoryExists as jest.MockedFunction<
typeof fileSystem.checkDirectoryExists
>
).mockImplementation(async p => String(p).includes('skills'));
(
fileSystem.isDirectoryEmpty as jest.MockedFunction<
typeof fileSystem.isDirectoryEmpty
>
).mockResolvedValue(true);
(
fs.default.existsSync as jest.MockedFunction<typeof fs.existsSync>
).mockReturnValue(false);

await projectCreator.create('test-project', { yes: true, skills: true });

expect(
(fs.default.writeFile as unknown as jest.Mock).mock.calls.some(([p]) =>
String(p).includes('launch.json')
)
).toBe(true);
});

it('does not write launch.json when the skills opt-in is declined', async () => {
(
fileSystem.checkDirectoryExists as jest.MockedFunction<
typeof fileSystem.checkDirectoryExists
>
).mockImplementation(async p => String(p).includes('skills'));
(
fileSystem.isDirectoryEmpty as jest.MockedFunction<
typeof fileSystem.isDirectoryEmpty
>
).mockResolvedValue(true);
(
fs.default.existsSync as jest.MockedFunction<typeof fs.existsSync>
).mockReturnValue(false);

await projectCreator.create('test-project', { yes: true, skills: false });

expect(
(fs.default.writeFile as unknown as jest.Mock).mock.calls.some(([p]) =>
String(p).includes('launch.json')
)
).toBe(false);
});

it('should prompt for missing template option', async () => {
const options = {
bulma: 'complete',
Expand Down
30 changes: 29 additions & 1 deletion create-bestax/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,33 @@ export const MESSAGES = {
NEXT_STEPS: 'Next steps:',
HAPPY_CODING: 'Happy coding! 🎉',
SKILLS_ADDED:
'✔ Installed bestax AI skills into .claude/skills/ (+ CLAUDE.md)',
'✔ Installed bestax AI skills into .claude/skills/ (+ CLAUDE.md, .claude/launch.json)',
} as const;

// Dev-server manifest written into .claude/ with the skills opt-in so Claude
// Code's browser preview (`preview_start`) can boot the app by name instead of
// rediscovering the dev command from package.json. `npm` is deliberately
// package-manager-neutral (`npm run dev` works whichever PM installed the
// project), and `--strictPort` stops Vite from silently auto-incrementing to
// 5174 when 5173 is busy — a mismatch with the declared `port` must fail
// loudly, not point the preview at nothing.
export const LAUNCH_JSON =
JSON.stringify(
{
version: '0.0.1',
configurations: [
{
name: 'dev',
runtimeExecutable: 'npm',
runtimeArgs: ['run', 'dev', '--', '--strictPort'],
port: 5173,
},
],
},
null,
2
) + '\n';

export const PROMPTS = {
PROJECT_NAME: 'Project name:',
SELECT_FRAMEWORK: 'Select a framework:',
Expand Down Expand Up @@ -131,6 +155,10 @@ automatically when the task matches:

Prefer the library's components and these skills over hand-written Bulma markup or custom CSS.

\`.claude/launch.json\` declares this app's dev server for Claude Code's browser preview
(\`npm run dev\` on port 5173, \`--strictPort\`) — start it from there rather than rediscovering
the command.

## Docs

- Docs site: https://bestax.io
Expand Down
5 changes: 5 additions & 0 deletions create-bestax/src/project-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ICON_LIBRARIES,
BULMA_FLAVORS,
CLAUDE_MD,
LAUNCH_JSON,
CONFIG_PROVIDER_ICON_VALUES,
type ClaudeMdOptions,
} from './constants.js';
Expand Down Expand Up @@ -117,6 +118,10 @@ export class ProjectCreator {
}

await fs.copy(skillsSrc, path.join(targetPath, '.claude', 'skills'));
await fs.writeFile(
path.join(targetPath, '.claude', 'launch.json'),
LAUNCH_JSON
);
await fs.writeFile(
path.join(targetPath, 'CLAUDE.md'),
CLAUDE_MD(projectName, options)
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/guides/getting-started/ai-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,11 @@ machine.
reading it.
- **Kill switches**: removing the `ai-loop` label stops one PR; a repository variable turns
the whole system off.

## AI-ready scaffolds

New apps can start AI-ready too: accepting the AI-skills prompt in `npm create bestax@latest`
installs the bestax Agent Skills and a `CLAUDE.md`, plus a `.claude/launch.json` that tells
Claude Code's browser preview how to start the app's dev server (`npm run dev` on port 5173,
with `--strictPort` so a busy port fails loudly instead of silently drifting to another port).
See the [LLMs guide](/docs/guides/llms) for the full AI tooling story.
7 changes: 4 additions & 3 deletions docs/docs/guides/llms/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ npx skills add https://github.com/allxsmith/bestax --skill bestax-layout-scaffol
```

Starting a new app? `pnpm create bestax@latest` offers to **preinstall these skills**
into the generated app's `.claude/skills/` (alongside a `CLAUDE.md`), so a Claude Code
session picks them up automatically. See the [Skills overview](/docs/skills/intro) for
what each one does.
into the generated app's `.claude/skills/` (alongside a `CLAUDE.md` and a
`.claude/launch.json` that lets Claude Code's browser preview start the dev server by
name), so a Claude Code session picks them up automatically. See the
[Skills overview](/docs/skills/intro) for what each one does.

## MCP server (coming soon)

Expand Down
Loading