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
6 changes: 6 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# rafters

## Unreleased

### Minor Changes

- feat(config): multi-folder paths for components, primitives, composites, rules (#1420). Each path field in `.rafters/config.rafters.json` now accepts either the existing single string or an array of entries, so a project can read assets from external folders (e.g. `@shingle/shared`) on top of its own. Entries are plain strings or `{ path, root: true }` objects; the install root for `rafters add` is the entry tagged `{ root: true }`, otherwise the first entry whose realpath resolves inside cwd, otherwise the framework default at cwd. Local always wins on collision -- the install root is always first in the read set so first-write-wins semantics in loaders produce local-wins reads. New `rulesPath` field added with the same semantics. New helpers `resolveRoot` and `resolveReadSet` in `utils/paths.ts`. MCP composite loader now iterates the resolved read set instead of a hardcoded `.rafters/composites` path, so shared composite packages are queryable through `rafters_composite`.

## 0.0.53

### Minor Changes
Expand Down
84 changes: 43 additions & 41 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type InstallRegistryDepsResult,
installRegistryDependencies,
} from '../utils/install-registry-deps.js';
import { getRaftersPaths } from '../utils/paths.js';
import { getRaftersPaths, type PathField, resolveRoot } from '../utils/paths.js';
import { error, log, setAgentMode } from '../utils/ui.js';
import type { RaftersConfig } from './init.js';

Expand Down Expand Up @@ -170,53 +170,53 @@ export function isAlreadyInstalled(config: RaftersConfig | null, item: RegistryI
*/
export function trackInstalled(config: RaftersConfig, items: RegistryItem[]): void {
if (!config.installed) {
config.installed = { components: [], primitives: [], composites: [] };
}
if (!config.installed.composites) {
config.installed.composites = [];
config.installed = { components: [], primitives: [], composites: [], rules: [] };
}
const installed = config.installed;
if (!installed.composites) installed.composites = [];
if (!installed.rules) installed.rules = [];
for (const item of items) {
if (item.type === 'ui') {
if (!config.installed.components.includes(item.name)) {
config.installed.components.push(item.name);
}
} else if (item.type === 'composite') {
if (!config.installed.composites.includes(item.name)) {
config.installed.composites.push(item.name);
}
} else {
if (!config.installed.primitives.includes(item.name)) {
config.installed.primitives.push(item.name);
}
}
const bucket =
item.type === 'ui'
? installed.components
: item.type === 'composite'
? installed.composites
: installed.primitives;
if (!bucket.includes(item.name)) bucket.push(item.name);
}
config.installed.components.sort();
config.installed.primitives.sort();
config.installed.composites.sort();
installed.components.sort();
installed.primitives.sort();
installed.composites.sort();
installed.rules.sort();
}

/**
* Resolve the install root for a config path field. Path fields accept a
* single string or an array of entries; this returns the relative folder
* `rafters add` should write into. See {@link resolveRoot} for precedence.
*/
function rootFor(field: PathField | undefined, cwd: string, fallback: string): string {
return field === undefined ? fallback : resolveRoot(field, cwd, fallback);
}

/**
* Transform registry path to project path based on config
* e.g., "components/ui/button.tsx" -> "app/components/ui/button.tsx"
*/
function transformPath(registryPath: string, config: RaftersConfig | null): string {
function transformPath(registryPath: string, config: RaftersConfig | null, cwd: string): string {
if (!config) return registryPath;

// Transform component paths
if (registryPath.startsWith('components/ui/')) {
return registryPath.replace('components/ui/', `${config.componentsPath}/`);
}

// Transform primitive paths
if (registryPath.startsWith('lib/primitives/')) {
return registryPath.replace('lib/primitives/', `${config.primitivesPath}/`);
}

// Transform composite paths
if (registryPath.startsWith('composites/')) {
return registryPath.replace('composites/', `${config.compositesPath}/`);
const replacements: Array<[string, PathField, string]> = [
['components/ui/', config.componentsPath, 'components/ui'],
['lib/primitives/', config.primitivesPath, 'lib/primitives'],
['composites/', config.compositesPath, 'composites'],
['rules/', config.rulesPath, 'rules'],
];
for (const [prefix, field, fallback] of replacements) {
if (registryPath.startsWith(prefix)) {
return registryPath.replace(prefix, `${rootFor(field, cwd, fallback)}/`);
}
}

return registryPath;
}

Expand All @@ -240,12 +240,13 @@ export function transformFileContent(
content: string,
config: RaftersConfig | null,
fileType: 'component' | 'primitive' = 'component',
cwd: string = process.cwd(),
): string {
let transformed = content;

// Get paths from config or use defaults
const componentsPath = config?.componentsPath ?? 'components/ui';
const primitivesPath = config?.primitivesPath ?? 'lib/primitives';
const componentsPath = rootFor(config?.componentsPath, cwd, 'components/ui');
const primitivesPath = rootFor(config?.primitivesPath, cwd, 'lib/primitives');

// Strip source root prefix (src/, app/) for @/ alias imports.
// Config paths are filesystem paths (src/components/ui) but @/ alias
Expand Down Expand Up @@ -329,7 +330,7 @@ async function installItem(

for (const file of filesToInstall) {
// Transform the path based on project config
const projectPath = transformPath(file.path, config);
const projectPath = transformPath(file.path, config, cwd);
const targetPath = join(cwd, projectPath);

// Check if file exists and handle overwrite
Expand All @@ -351,7 +352,7 @@ async function installItem(

// Transform and write the file
const fileType = item.type === 'primitive' ? 'primitive' : 'component';
const transformedContent = transformFileContent(file.content, config, fileType);
const transformedContent = transformFileContent(file.content, config, fileType, cwd);
await writeFile(targetPath, transformedContent, 'utf-8');

installedFiles.push(projectPath);
Expand Down Expand Up @@ -646,10 +647,11 @@ export async function add(componentArgs: string[], options: AddOptions): Promise
componentsPath: 'components/ui',
primitivesPath: 'lib/primitives',
compositesPath: 'composites',
rulesPath: 'rules',
cssPath: null,
shadcn: false,
exports: DEFAULT_EXPORTS,
installed: { components: [], primitives: [], composites: [] },
installed: { components: [], primitives: [], composites: [], rules: [] },
};
trackInstalled(newConfig, installedItems);
await saveConfig(cwd, newConfig);
Expand Down
63 changes: 40 additions & 23 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
FUTURE_EXPORTS,
selectionsToConfig,
} from '../utils/exports.js';
import { getRaftersPaths } from '../utils/paths.js';
import { getRaftersPaths, type PathField } from '../utils/paths.js';
import { isAgentMode, log, setAgentMode } from '../utils/ui.js';
import { updateDependencies } from '../utils/update-dependencies.js';

Expand Down Expand Up @@ -83,42 +83,58 @@ const CSS_LOCATIONS: Record<Framework, string[]> = {
};

// Default component paths per framework
const COMPONENT_PATHS: Record<
export const COMPONENT_PATHS: Record<
Framework,
{ components: string; primitives: string; composites: string }
{ components: string; primitives: string; composites: string; rules: string }
> = {
astro: {
components: 'src/components/ui',
primitives: 'src/lib/primitives',
composites: 'src/composites',
rules: 'src/rules',
},
next: {
components: 'components/ui',
primitives: 'lib/primitives',
composites: 'composites',
rules: 'rules',
},
next: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' },
vite: {
components: 'src/components/ui',
primitives: 'src/lib/primitives',
composites: 'src/composites',
rules: 'src/rules',
},
remix: {
components: 'app/components/ui',
primitives: 'app/lib/primitives',
composites: 'app/composites',
rules: 'app/rules',
},
'react-router': {
components: 'app/components/ui',
primitives: 'app/lib/primitives',
composites: 'app/composites',
rules: 'app/rules',
},
wc: {
components: 'src/components/ui',
primitives: 'src/lib/primitives',
composites: 'src/composites',
rules: 'src/rules',
},
vanilla: {
components: 'src/components/ui',
primitives: 'src/lib/primitives',
composites: 'src/composites',
rules: 'src/rules',
},
unknown: {
components: 'components/ui',
primitives: 'lib/primitives',
composites: 'composites',
rules: 'rules',
},
unknown: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' },
};

/**
Expand All @@ -136,34 +152,29 @@ const FRAMEWORK_PROMPT_LABELS: Record<Exclude<Framework, 'unknown'>, string> = {

/**
* Configuration persisted in `.rafters/config.rafters.json`.
* Used by the CLI to resolve framework-specific defaults and perform
* path transformations when generating or updating files.
* All paths are relative to the project root.
*
* Path fields accept either a single string (status quo) or an array of
* entries to support multi-folder layouts (e.g. project + `@shingle/shared`).
* When multiple entries are provided, the install root is the entry tagged
* `{ root: true }`, otherwise the first entry whose realpath resolves inside
* cwd. Local entries always win on collision.
*/
export interface RaftersConfig {
/** Detected or selected application framework */
framework: Framework;
/** Which file variant to install: react (.tsx), astro (.astro), vue (.vue), svelte (.svelte) */
componentTarget?: ComponentTarget;
/** Root directory for UI components, e.g. `components/ui` or `app/components/ui` */
componentsPath: string;
/** Root directory for primitive components, e.g. `lib/primitives` */
primitivesPath: string;
/** Root directory for composite JSON files, e.g. `src/composites` */
compositesPath: string;
/** Entry CSS file for design tokens, or null if not detected */
componentsPath: PathField;
primitivesPath: PathField;
compositesPath: PathField;
rulesPath: PathField;
cssPath: string | null;
/** Whether shadcn/ui was detected in the project */
shadcn: boolean;
/** Export format selections */
exports: ExportConfig;
/** Dark mode strategy: 'class' (default, .dark class toggle) or 'media' (OS preference) */
darkMode?: 'class' | 'media';
/** Items installed via `rafters add` */
installed?: {
components: string[];
primitives: string[];
composites: string[];
rules: string[];
};
}

Expand Down Expand Up @@ -416,6 +427,7 @@ async function regenerateFromExisting(
existingConfig.componentsPath = frameworkPaths.components;
existingConfig.primitivesPath = frameworkPaths.primitives;
existingConfig.compositesPath = frameworkPaths.composites;
existingConfig.rulesPath = frameworkPaths.rules;
}

// Load all tokens from .rafters/tokens/
Expand Down Expand Up @@ -469,10 +481,11 @@ async function regenerateFromExisting(
componentsPath: frameworkPaths.components,
primitivesPath: frameworkPaths.primitives,
compositesPath: frameworkPaths.composites,
rulesPath: frameworkPaths.rules,
cssPath: null,
shadcn: !!shadcn,
exports,
installed: { components: [], primitives: [], composites: [] },
installed: { components: [], primitives: [], composites: [], rules: [] },
};
await writeFile(paths.config, JSON.stringify(newConfig, null, 2));
}
Expand Down Expand Up @@ -509,6 +522,7 @@ async function resetToDefaults(
existingConfig.componentsPath = frameworkPaths.components;
existingConfig.primitivesPath = frameworkPaths.primitives;
existingConfig.compositesPath = frameworkPaths.composites;
existingConfig.rulesPath = frameworkPaths.rules;
}

// Load existing tokens to check for userOverride backups
Expand Down Expand Up @@ -598,10 +612,11 @@ async function resetToDefaults(
componentsPath: frameworkPaths.components,
primitivesPath: frameworkPaths.primitives,
compositesPath: frameworkPaths.composites,
rulesPath: frameworkPaths.rules,
cssPath: null,
shadcn: !!shadcn,
exports,
installed: { components: [], primitives: [], composites: [] },
installed: { components: [], primitives: [], composites: [], rules: [] },
};
await writeFile(paths.config, JSON.stringify(newConfig, null, 2));
}
Expand Down Expand Up @@ -869,13 +884,15 @@ export async function init(options: InitOptions): Promise<void> {
componentsPath: frameworkPaths.components,
primitivesPath: frameworkPaths.primitives,
compositesPath: frameworkPaths.composites,
rulesPath: frameworkPaths.rules,
cssPath: detectedCssPath,
shadcn: !!shadcn,
exports,
installed: {
components: [],
primitives: [],
composites: [],
rules: [],
},
};
await writeFile(paths.config, JSON.stringify(config, null, 2));
Expand Down
31 changes: 27 additions & 4 deletions packages/cli/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Token import lives in `rafters init` / `rafters import`, not MCP.
*/

import { readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import {
Expand All @@ -24,8 +24,9 @@ import {
registerComposite,
searchComposites,
} from '@rafters/composites';
import type { RaftersConfig } from '../commands/init.js';
import { registryClient } from '../registry/client.js';
import { getRaftersPaths } from '../utils/paths.js';
import { getRaftersPaths, resolveReadSet } from '../utils/paths.js';
import { resolveWorkspace, type Workspace } from '../utils/workspaces.js';

const WORKSPACE_PARAM = {
Expand Down Expand Up @@ -258,12 +259,34 @@ export class RaftersToolHandler {
}

if (workspace && !this.compositesLoadedFor.has(workspace.root)) {
const paths = getRaftersPaths(workspace.root);
await this.loadCompositesFromDir(join(paths.root, 'composites'));
for (const dir of await this.compositeReadRoots(workspace.root)) {
await this.loadCompositesFromDir(dir);
}
this.compositesLoadedFor.add(workspace.root);
}
}

/**
* Resolve the set of folders to scan for composite manifests in a workspace.
* Reads `.rafters/config.rafters.json` and applies the workspace's
* `compositesPath` (which may be a string or an array of entries to support
* shared packages like `@shingle/shared`). Falls back to `.rafters/composites`
* when no config or compositesPath is set.
*/
private async compositeReadRoots(workspaceRoot: string): Promise<string[]> {
const paths = getRaftersPaths(workspaceRoot);
let config: RaftersConfig | null = null;
try {
config = JSON.parse(await readFile(paths.config, 'utf-8')) as RaftersConfig;
} catch {
// No config -- fall through to default
}
if (!config?.compositesPath) {
return [join(paths.root, 'composites')];
}
return resolveReadSet(config.compositesPath, workspaceRoot);
}

private async handleComposite(args: Record<string, unknown>): Promise<CallToolResult> {
const { id, query, category, workspace } = args as {
id?: string;
Expand Down
Loading
Loading