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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ module.exports = {

For plugin configuration options, see the [`@domscribe/transform` README](./packages/domscribe-transform/README.md).

#### Monorepos

If your frontend app is in a subdirectory (e.g. `apps/web`), pass `--app-root` during init:

```bash
npx domscribe init --app-root apps/web
```

Or run `npx domscribe init` and follow the prompts — the wizard asks if you're in a monorepo.

This creates a `domscribe.config.json` at your repo root that tells all Domscribe tools where your app lives. CLI commands (`serve`, `stop`, `status`) and agent MCP connections automatically resolve the app root from this config — no extra flags needed.

### Agent-Side — Connect Your Coding Agent

Domscribe exposes 12 tools and 4 prompts via MCP. Agent plugins bundle the MCP config and a skill file that teaches the agent how to use the tools effectively.
Expand Down
1 change: 1 addition & 0 deletions packages/domscribe-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

// Export all types
export * from './lib/types/annotation.js';
export * from './lib/types/config.js';
export * from './lib/types/manifest.js';
export * from './lib/types/nullable.js';

Expand Down
1 change: 1 addition & 0 deletions packages/domscribe-core/src/lib/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const PATHS = {
AGENT_INSTRUCTIONS: '.domscribe/agent-instructions',

// Config files
CONFIG_JSON_FILE: 'domscribe.config.json',
CONFIG_FILE: 'domscribe.config.ts',
CONFIG_JS_FILE: 'domscribe.config.js',
VALIDATION_RECIPE: 'domscribe.validation.yaml',
Expand Down
23 changes: 23 additions & 0 deletions packages/domscribe-core/src/lib/types/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Domscribe project configuration schema.
* @module @domscribe/core/types/config
*/
import { z } from 'zod';

/**
* Schema for `domscribe.config.json`.
*
* @remarks
* Used in monorepo setups where the coding agent starts at the repo root
* but the frontend app lives in a subdirectory. The config file sits at
* the repo root and points to the app root where `.domscribe/` is located.
*/
export const DomscribeConfigSchema = z.object({
appRoot: z
.string()
.describe(
'Relative path from the config file to the frontend app root directory',
),
});

export type DomscribeConfig = z.infer<typeof DomscribeConfigSchema>;
2 changes: 2 additions & 0 deletions packages/domscribe-relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ domscribe mcp # Run as MCP server via stdio

For use in agent MCP configuration, the standalone `domscribe-mcp` binary runs the MCP server directly over stdio without the HTTP/WebSocket relay.

**Monorepo support:** All commands automatically resolve the app root from a `domscribe.config.json` file when run from a monorepo root. Run `domscribe init --app-root <path>` to generate the config, or let the interactive wizard detect it.

## Annotation Lifecycle

A developer clicks an element in the running app, types an instruction, and submits it. The annotation moves through the following states:
Expand Down
3 changes: 3 additions & 0 deletions packages/domscribe-relay/src/cli/commands/init.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface InitCommandOptions {
agent?: string;
framework?: string;
pm?: string;
appRoot?: string;
}

export const InitCommand = new Command('init')
Expand All @@ -26,6 +27,7 @@ export const InitCommand = new Command('init')
`Framework + bundler (${FRAMEWORK_IDS.join(', ')})`,
)
.option('--pm <name>', `Package manager (${PACKAGE_MANAGER_IDS.join(', ')})`)
.option('--app-root <path>', 'Path to frontend app root (for monorepos)')
.action(async (options: InitCommandOptions) => {
try {
if (options.agent && !AGENT_IDS.includes(options.agent as never)) {
Expand Down Expand Up @@ -58,6 +60,7 @@ export const InitCommand = new Command('init')
agent: options.agent as InitOptions['agent'],
framework: options.framework as InitOptions['framework'],
pm: options.pm as InitOptions['pm'],
appRoot: options.appRoot,
};

await runInitWizard(initOptions);
Expand Down
110 changes: 110 additions & 0 deletions packages/domscribe-relay/src/cli/config-loader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { existsSync, readFileSync } from 'node:fs';

import { findConfigFile, loadAppRoot } from './config-loader.js';

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));

describe('findConfigFile', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should return JSON config path when it exists', () => {
// Arrange
vi.mocked(existsSync).mockImplementation(
(p) => String(p) === '/project/domscribe.config.json',
);

// Act
const result = findConfigFile('/project');

// Assert
expect(result).toBe('/project/domscribe.config.json');
});

it('should prefer JSON over JS and TS', () => {
// Arrange
vi.mocked(existsSync).mockReturnValue(true);

// Act
const result = findConfigFile('/project');

// Assert
expect(result).toBe('/project/domscribe.config.json');
});

it('should fall back to JS when JSON does not exist', () => {
// Arrange
vi.mocked(existsSync).mockImplementation(
(p) => String(p) === '/project/domscribe.config.js',
);

// Act
const result = findConfigFile('/project');

// Assert
expect(result).toBe('/project/domscribe.config.js');
});

it('should return undefined when no config file exists', () => {
// Arrange
vi.mocked(existsSync).mockReturnValue(false);

// Act
const result = findConfigFile('/project');

// Assert
expect(result).toBeUndefined();
});
});

describe('loadAppRoot', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should parse valid JSON and resolve appRoot', () => {
// Arrange
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ appRoot: './apps/web' }),
);

// Act
const result = loadAppRoot('/monorepo/domscribe.config.json');

// Assert
expect(result).toBe('/monorepo/apps/web');
});

it('should resolve relative paths with parent traversal', () => {
// Arrange
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ appRoot: '../frontend' }),
);

// Act
const result = loadAppRoot('/monorepo/config/domscribe.config.json');

// Assert
expect(result).toBe('/monorepo/frontend');
});

it('should throw on missing appRoot field', () => {
// Arrange
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({}));

// Act & Assert
expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow();
});

it('should throw on invalid JSON', () => {
// Arrange
vi.mocked(readFileSync).mockReturnValue('not json');

// Act & Assert
expect(() => loadAppRoot('/project/domscribe.config.json')).toThrow();
});
});
41 changes: 41 additions & 0 deletions packages/domscribe-relay/src/cli/config-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Domscribe config file discovery and loading.
* @module @domscribe/relay/cli/config-loader
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';

import { DomscribeConfigSchema, PATHS } from '@domscribe/core';

/**
* Config filenames to scan, in priority order.
* JSON is preferred; `.js` and `.ts` are reserved for future use.
*/
const CONFIG_FILENAMES = [
PATHS.CONFIG_JSON_FILE,
PATHS.CONFIG_JS_FILE,
PATHS.CONFIG_FILE,
] as const;

/**
* Find a Domscribe config file in the given directory.
* Returns the absolute path to the first match, or `undefined`.
*/
export function findConfigFile(dir: string): string | undefined {
for (const name of CONFIG_FILENAMES) {
const filePath = path.join(dir, name);
if (existsSync(filePath)) return filePath;
}
return undefined;
}

/**
* Read a `domscribe.config.json` and resolve the `appRoot` to an absolute path
* relative to the config file's directory.
*/
export function loadAppRoot(configPath: string): string {
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
const config = DomscribeConfigSchema.parse(parsed);
return path.resolve(path.dirname(configPath), config.appRoot);
}
44 changes: 31 additions & 13 deletions packages/domscribe-relay/src/cli/init/framework-step.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';

import * as clack from '@clack/prompts';

import { runFrameworkStep } from './framework-step.js';
import type { InitOptions } from './types.js';

/**
* Create a fake ChildProcess that emits 'close' on the next tick.
*/
function createFakeChild(exitCode = 0, stderrData = ''): EventEmitter {
const child = new EventEmitter();
const stderr = new EventEmitter();
(child as unknown as Record<string, unknown>).stderr = stderr;

process.nextTick(() => {
if (stderrData) {
stderr.emit('data', Buffer.from(stderrData));
}
child.emit('close', exitCode);
});

return child;
}

vi.mock('node:child_process', () => ({
spawnSync: vi.fn().mockReturnValue({ status: 0 }),
spawn: vi.fn(() => createFakeChild(0)),
}));

vi.mock('@clack/prompts', () => ({
Expand Down Expand Up @@ -40,9 +59,9 @@ describe('runFrameworkStep', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(clack.isCancel).mockReturnValue(false);
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<
typeof spawnSync
>);
vi.mocked(spawn).mockImplementation(
() => createFakeChild(0) as ReturnType<typeof spawn>,
);
});

describe('interactive mode', () => {
Expand Down Expand Up @@ -109,7 +128,7 @@ describe('runFrameworkStep', () => {

// Assert
expect(clack.select).not.toHaveBeenCalled();
expect(spawnSync).toHaveBeenCalledWith(
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['add', '-D', '@domscribe/nuxt'],
expect.objectContaining({ cwd: '/project' }),
Expand All @@ -128,7 +147,7 @@ describe('runFrameworkStep', () => {
await runFrameworkStep(baseOptions, '/project');

// Assert
expect(spawnSync).toHaveBeenCalledWith(
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '-D', '@domscribe/next'],
expect.objectContaining({ cwd: '/project' }),
Expand All @@ -145,7 +164,7 @@ describe('runFrameworkStep', () => {
await runFrameworkStep(baseOptions, '/project');

// Assert
expect(spawnSync).toHaveBeenCalledWith(
expect(spawn).toHaveBeenCalledWith(
'pnpm',
['add', '-D', '@domscribe/react'],
expect.objectContaining({ cwd: '/project' }),
Expand All @@ -157,10 +176,9 @@ describe('runFrameworkStep', () => {
vi.mocked(clack.select)
.mockResolvedValueOnce('nuxt')
.mockResolvedValueOnce('npm');
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stderr: Buffer.from('ERR'),
} as unknown as ReturnType<typeof spawnSync>);
vi.mocked(spawn).mockImplementation(
() => createFakeChild(1, 'ERR') as ReturnType<typeof spawn>,
);

// Act
await runFrameworkStep(baseOptions, '/project');
Expand Down Expand Up @@ -224,7 +242,7 @@ describe('runFrameworkStep', () => {
await runFrameworkStep(options, '/project');

// Assert
expect(spawnSync).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(clack.log.info).toHaveBeenCalledWith(
expect.stringContaining('npm install -D @domscribe/nuxt'),
);
Expand Down
Loading
Loading