diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap index 53585e339..dd48e9391 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap @@ -362,7 +362,12 @@ No developer or entity involved in creating this software will be liable for any `; exports[`cli docs generator generates CLI skill files 1`] = ` -"# myapp-context +"--- +name: cli-context +description: Manage API endpoint contexts for myapp +--- + +# cli-context @@ -396,7 +401,12 @@ myapp context list `; exports[`cli docs generator generates CLI skill files 2`] = ` -"# myapp-auth +"--- +name: cli-auth +description: Manage authentication tokens for myapp +--- + +# cli-auth @@ -427,7 +437,12 @@ myapp auth status `; exports[`cli docs generator generates CLI skill files 3`] = ` -"# myapp-car +"--- +name: cli-default-car +description: CRUD operations for Car records via myapp CLI +--- + +# cli-default-car @@ -478,7 +493,12 @@ myapp car delete --id `; exports[`cli docs generator generates CLI skill files 4`] = ` -"# myapp-driver +"--- +name: cli-default-driver +description: CRUD operations for Driver records via myapp CLI +--- + +# cli-default-driver @@ -529,7 +549,12 @@ myapp driver delete --id `; exports[`cli docs generator generates CLI skill files 5`] = ` -"# myapp-current-user +"--- +name: cli-default-current-user +description: Get the currently authenticated user +--- + +# cli-default-current-user @@ -552,7 +577,12 @@ myapp current-user `; exports[`cli docs generator generates CLI skill files 6`] = ` -"# myapp-login +"--- +name: cli-default-login +description: Authenticate a user +--- + +# cli-default-login @@ -1781,7 +1811,12 @@ No developer or entity involved in creating this software will be liable for any `; exports[`hooks docs generator generates hooks skill files 1`] = ` -"# hooks-car +"--- +name: hooks-default-car +description: React Query hooks for Car data operations +--- + +# hooks-default-car @@ -1819,7 +1854,12 @@ mutate({ make: '', model: '', year: '', isElectric: ' @@ -1857,7 +1897,12 @@ mutate({ name: '', licenseNumber: '' }); `; exports[`hooks docs generator generates hooks skill files 3`] = ` -"# hooks-currentUser +"--- +name: hooks-default-current-user +description: Get the currently authenticated user +--- + +# hooks-default-current-user @@ -1880,7 +1925,12 @@ const { data, isLoading } = useCurrentUserQuery(); `; exports[`hooks docs generator generates hooks skill files 4`] = ` -"# hooks-login +"--- +name: hooks-default-login +description: Authenticate a user +--- + +# hooks-default-login @@ -2905,7 +2955,12 @@ No developer or entity involved in creating this software will be liable for any exports[`multi-target cli docs generates multi-target skills 1`] = ` [ { - "content": "# myapp-context + "content": "--- +name: cli-context +description: Manage API endpoint contexts for myapp (multi-target: auth, members, app) +--- + +# cli-context @@ -2944,10 +2999,15 @@ myapp context list myapp context use staging \`\`\` ", - "fileName": "skills/context.md", + "fileName": "cli-context/SKILL.md", }, { - "content": "# myapp-credentials + "content": "--- +name: cli-auth +description: Manage authentication tokens for myapp (shared across all targets) +--- + +# cli-auth @@ -2975,10 +3035,15 @@ myapp credentials set-token eyJhbGciOiJIUzI1NiIs... myapp credentials status \`\`\` ", - "fileName": "skills/credentials.md", + "fileName": "cli-auth/SKILL.md", }, { - "content": "# myapp-auth:user + "content": "--- +name: cli-auth-user +description: CRUD operations for User records via myapp CLI (auth target) +--- + +# cli-auth-user @@ -3014,10 +3079,15 @@ myapp auth:user create --email "value" --name "value" myapp auth:user get --id \`\`\` ", - "fileName": "skills/auth-user.md", + "fileName": "cli-auth-user/SKILL.md", }, { - "content": "# myapp-auth:current-user + "content": "--- +name: cli-auth-current-user +description: Get the currently authenticated user (auth target) +--- + +# cli-auth-current-user @@ -3037,10 +3107,15 @@ myapp auth:current-user myapp auth:current-user \`\`\` ", - "fileName": "skills/auth-current-user.md", + "fileName": "cli-auth-current-user/SKILL.md", }, { - "content": "# myapp-auth:login + "content": "--- +name: cli-auth-login +description: Authenticate a user (auth target) +--- + +# cli-auth-login @@ -3061,10 +3136,15 @@ myapp auth:login --email --password --save-token myapp auth:login --email --password \`\`\` ", - "fileName": "skills/auth-login.md", + "fileName": "cli-auth-login/SKILL.md", }, { - "content": "# myapp-members:member + "content": "--- +name: cli-members-member +description: CRUD operations for Member records via myapp CLI (members target) +--- + +# cli-members-member @@ -3100,10 +3180,15 @@ myapp members:member create --role "value" myapp members:member get --id \`\`\` ", - "fileName": "skills/members-member.md", + "fileName": "cli-members-member/SKILL.md", }, { - "content": "# myapp-app:car + "content": "--- +name: cli-app-car +description: CRUD operations for Car records via myapp CLI (app target) +--- + +# cli-app-car @@ -3139,7 +3224,7 @@ myapp app:car create --make "value" --model "value" --year "value" --isElectric myapp app:car get --id \`\`\` ", - "fileName": "skills/app-car.md", + "fileName": "cli-app-car/SKILL.md", }, ] `; @@ -4502,7 +4587,12 @@ No developer or entity involved in creating this software will be liable for any `; exports[`orm docs generator generates ORM skill files 1`] = ` -"# orm-car +"--- +name: orm-default-car +description: ORM operations for Car records +--- + +# orm-default-car @@ -4540,7 +4630,12 @@ const item = await db.car.create({ `; exports[`orm docs generator generates ORM skill files 2`] = ` -"# orm-driver +"--- +name: orm-default-driver +description: ORM operations for Driver records +--- + +# orm-default-driver @@ -4578,7 +4673,12 @@ const item = await db.driver.create({ `; exports[`orm docs generator generates ORM skill files 3`] = ` -"# orm-currentUser +"--- +name: orm-default-current-user +description: Get the currently authenticated user +--- + +# orm-default-current-user @@ -4601,7 +4701,12 @@ const result = await db.query.currentUser().execute(); `; exports[`orm docs generator generates ORM skill files 4`] = ` -"# orm-login +"--- +name: orm-default-login +description: Authenticate a user +--- + +# orm-default-login diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index 1a9b2c941..4839022b2 100644 --- a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts @@ -285,7 +285,7 @@ describe('cli docs generator', () => { }); it('generates CLI skill files', () => { - const skills = generateCliSkills([carTable, driverTable], allCustomOps, 'myapp'); + const skills = generateCliSkills([carTable, driverTable], allCustomOps, 'myapp', 'default'); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); @@ -317,7 +317,7 @@ describe('orm docs generator', () => { }); it('generates ORM skill files', () => { - const skills = generateOrmSkills([carTable, driverTable], allCustomOps); + const skills = generateOrmSkills([carTable, driverTable], allCustomOps, 'default'); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); @@ -349,7 +349,7 @@ describe('hooks docs generator', () => { }); it('generates hooks skill files', () => { - const skills = generateHooksSkills([carTable, driverTable], allCustomOps); + const skills = generateHooksSkills([carTable, driverTable], allCustomOps, 'default'); expect(skills.length).toBeGreaterThan(0); for (const sf of skills) { expect(sf.content).toMatchSnapshot(); diff --git a/graphql/codegen/src/core/codegen/cli/docs-generator.ts b/graphql/codegen/src/core/codegen/cli/docs-generator.ts index 605319f43..83fd43b8d 100644 --- a/graphql/codegen/src/core/codegen/cli/docs-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/docs-generator.ts @@ -591,13 +591,16 @@ export function generateSkills( tables: CleanTable[], customOperations: CleanOperation[], toolName: string, + targetName: string, ): GeneratedDocFile[] { const files: GeneratedDocFile[] = []; + const contextSkillName = 'cli-context'; + files.push({ - fileName: 'skills/context.md', + fileName: `${contextSkillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-context`, + name: contextSkillName, description: `Manage API endpoint contexts for ${toolName}`, usage: [ `${toolName} context create --endpoint `, @@ -622,10 +625,12 @@ export function generateSkills( }), }); + const authSkillName = 'cli-auth'; + files.push({ - fileName: 'skills/auth.md', + fileName: `${authSkillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-auth`, + name: authSkillName, description: `Manage authentication tokens for ${toolName}`, usage: [ `${toolName} auth set-token `, @@ -651,10 +656,12 @@ export function generateSkills( const pk = getPrimaryKeyInfo(table)[0]; const editableFields = getEditableFields(table); + const skillName = `cli-${targetName}-${kebab}`; + files.push({ - fileName: `skills/${kebab}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${kebab}`, + name: skillName, description: `CRUD operations for ${table.name} records via ${toolName} CLI`, usage: [ `${toolName} ${kebab} list`, @@ -700,10 +707,12 @@ export function generateSkills( ? `${toolName} ${kebab} ${op.args.map((a) => `--${a.name} `).join(' ')}` : `${toolName} ${kebab}`; + const skillName = `cli-${targetName}-${kebab}`; + files.push({ - fileName: `skills/${kebab}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${kebab}`, + name: skillName, description: op.description || `Execute the ${op.name} ${op.kind}`, usage: [usage], examples: [ @@ -1428,10 +1437,12 @@ export function generateMultiTargetSkills( const contextCreateFlags = targets .map((t) => `--${t.name}-endpoint `) .join(' '); + const contextSkillName = 'cli-context'; + files.push({ - fileName: `skills/${builtinNames.context}.md`, + fileName: `${contextSkillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${builtinNames.context}`, + name: contextSkillName, description: `Manage API endpoint contexts for ${toolName} (multi-target: ${targets.map((t) => t.name).join(', ')})`, usage: contextUsage, examples: [ @@ -1460,10 +1471,12 @@ export function generateMultiTargetSkills( }), }); + const authSkillName = 'cli-auth'; + files.push({ - fileName: `skills/${builtinNames.auth}.md`, + fileName: `${authSkillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${builtinNames.auth}`, + name: authSkillName, description: `Manage authentication tokens for ${toolName} (shared across all targets)`, usage: [ `${toolName} ${builtinNames.auth} set-token `, @@ -1491,10 +1504,12 @@ export function generateMultiTargetSkills( const editableFields = getEditableFields(table); const cmd = `${tgt.name}:${kebab}`; + const skillName = `cli-${tgt.name}-${kebab}`; + files.push({ - fileName: `skills/${tgt.name}-${kebab}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${cmd}`, + name: skillName, description: `CRUD operations for ${table.name} records via ${toolName} CLI (${tgt.name} target)`, usage: [ `${toolName} ${cmd} list`, @@ -1535,10 +1550,12 @@ export function generateMultiTargetSkills( usageLines.push(`${baseUsage} --save-token`); } + const skillName = `cli-${tgt.name}-${kebab}`; + files.push({ - fileName: `skills/${tgt.name}-${kebab}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `${toolName}-${cmd}`, + name: skillName, description: `${op.description || `Execute the ${op.name} ${op.kind}`} (${tgt.name} target)`, usage: usageLines, examples: [ diff --git a/graphql/codegen/src/core/codegen/docs-utils.ts b/graphql/codegen/src/core/codegen/docs-utils.ts index 69d706173..751ffb587 100644 --- a/graphql/codegen/src/core/codegen/docs-utils.ts +++ b/graphql/codegen/src/core/codegen/docs-utils.ts @@ -123,6 +123,13 @@ export function buildSkillFile(skill: SkillDefinition): string { const lang = skill.language ?? 'bash'; const lines: string[] = []; + // YAML frontmatter (Agent Skills format) + lines.push('---'); + lines.push(`name: ${skill.name}`); + lines.push(`description: ${skill.description}`); + lines.push('---'); + lines.push(''); + lines.push(`# ${skill.name}`); lines.push(''); lines.push(''); diff --git a/graphql/codegen/src/core/codegen/hooks-docs-generator.ts b/graphql/codegen/src/core/codegen/hooks-docs-generator.ts index 2264eb45a..1cca73278 100644 --- a/graphql/codegen/src/core/codegen/hooks-docs-generator.ts +++ b/graphql/codegen/src/core/codegen/hooks-docs-generator.ts @@ -1,3 +1,5 @@ +import { toKebabCase } from 'komoji'; + import type { CleanOperation, CleanTable } from '../../types/schema'; import { buildSkillFile, @@ -496,6 +498,7 @@ export function getHooksMcpTools( export function generateHooksSkills( tables: CleanTable[], customOperations: CleanOperation[], + targetName: string, ): GeneratedDocFile[] { const files: GeneratedDocFile[] = []; @@ -507,10 +510,13 @@ export function generateHooksSkills( .map((f) => `${f.name}: true`) .join(', '); + const tableKebab = toKebabCase(singularName); + const skillName = `hooks-${targetName}-${tableKebab}`; + files.push({ - fileName: `skills/${lcFirst(singularName)}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `hooks-${lcFirst(singularName)}`, + name: skillName, description: table.description || `React Query hooks for ${table.name} data operations`, language: 'typescript', usage: [ @@ -558,10 +564,13 @@ export function generateHooksSkills( ? `{ ${op.args.map((a) => `${a.name}: ''`).join(', ')} }` : ''; + const opKebab = toKebabCase(op.name); + const skillName = `hooks-${targetName}-${opKebab}`; + files.push({ - fileName: `skills/${op.name}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `hooks-${op.name}`, + name: skillName, description: op.description || `React Query ${op.kind} hook for ${op.name}`, diff --git a/graphql/codegen/src/core/codegen/orm/docs-generator.ts b/graphql/codegen/src/core/codegen/orm/docs-generator.ts index fabc6159e..936c7b09a 100644 --- a/graphql/codegen/src/core/codegen/orm/docs-generator.ts +++ b/graphql/codegen/src/core/codegen/orm/docs-generator.ts @@ -1,3 +1,5 @@ +import { toKebabCase } from 'komoji'; + import type { CleanOperation, CleanTable } from '../../../types/schema'; import { buildSkillFile, @@ -449,6 +451,7 @@ export function getOrmMcpTools( export function generateOrmSkills( tables: CleanTable[], customOperations: CleanOperation[], + targetName: string, ): GeneratedDocFile[] { const files: GeneratedDocFile[] = []; @@ -457,24 +460,28 @@ export function generateOrmSkills( const pk = getPrimaryKeyInfo(table)[0]; const editableFields = getEditableFields(table); + const modelName = lcFirst(singularName); + const tableKebab = toKebabCase(singularName); + const skillName = `orm-${targetName}-${tableKebab}`; + files.push({ - fileName: `skills/${lcFirst(singularName)}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `orm-${lcFirst(singularName)}`, + name: skillName, description: table.description || `ORM operations for ${table.name} records`, language: 'typescript', usage: [ - `db.${lcFirst(singularName)}.findMany({ select: { id: true } }).execute()`, - `db.${lcFirst(singularName)}.findOne({ ${pk.name}: '', select: { id: true } }).execute()`, - `db.${lcFirst(singularName)}.create({ data: { ${editableFields.map((f) => `${f.name}: ''`).join(', ')} }, select: { id: true } }).execute()`, - `db.${lcFirst(singularName)}.update({ where: { ${pk.name}: '' }, data: { ${editableFields[0]?.name || 'field'}: '' }, select: { id: true } }).execute()`, - `db.${lcFirst(singularName)}.delete({ where: { ${pk.name}: '' } }).execute()`, + `db.${modelName}.findMany({ select: { id: true } }).execute()`, + `db.${modelName}.findOne({ ${pk.name}: '', select: { id: true } }).execute()`, + `db.${modelName}.create({ data: { ${editableFields.map((f) => `${f.name}: ''`).join(', ')} }, select: { id: true } }).execute()`, + `db.${modelName}.update({ where: { ${pk.name}: '' }, data: { ${editableFields[0]?.name || 'field'}: '' }, select: { id: true } }).execute()`, + `db.${modelName}.delete({ where: { ${pk.name}: '' } }).execute()`, ], examples: [ { description: `List all ${singularName} records`, code: [ - `const items = await db.${lcFirst(singularName)}.findMany({`, + `const items = await db.${modelName}.findMany({`, ` select: { ${pk.name}: true, ${editableFields[0]?.name || 'name'}: true }`, '}).execute();', ], @@ -482,7 +489,7 @@ export function generateOrmSkills( { description: `Create a ${singularName}`, code: [ - `const item = await db.${lcFirst(singularName)}.create({`, + `const item = await db.${modelName}.create({`, ` data: { ${editableFields.map((f) => `${f.name}: 'value'`).join(', ')} },`, ` select: { ${pk.name}: true }`, '}).execute();', @@ -500,21 +507,20 @@ export function generateOrmSkills( ? `{ ${op.args.map((a) => `${a.name}: ''`).join(', ')} }` : ''; + const opKebab = toKebabCase(op.name); + const skillName = `orm-${targetName}-${opKebab}`; + files.push({ - fileName: `skills/${op.name}.md`, + fileName: `${skillName}/SKILL.md`, content: buildSkillFile({ - name: `orm-${op.name}`, + name: skillName, description: op.description || `Execute the ${op.name} ${op.kind}`, language: 'typescript', - usage: [ - `db.${accessor}.${op.name}(${callArgs}).execute()`, - ], + usage: [`db.${accessor}.${op.name}(${callArgs}).execute()`], examples: [ { description: `Run ${op.name}`, - code: [ - `const result = await db.${accessor}.${op.name}(${callArgs}).execute();`, - ], + code: [`const result = await db.${accessor}.${op.name}(${callArgs}).execute();`], }, ], }), diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 0317c3482..9d0e8995d 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -56,6 +56,7 @@ import type { RootRootReadmeTarget } from './codegen/target-docs-generator'; import { createSchemaSource, validateSourceOptions } from './introspect'; import { writeGeneratedFiles } from './output'; import { runCodegenPipeline, validateTablesFound } from './pipeline'; +import { findWorkspaceRoot } from './workspace'; export interface GenerateOptions extends GraphQLSDKConfigTarget { authorization?: string; @@ -92,6 +93,29 @@ export interface GenerateResult { */ export interface GenerateInternalOptions { skipCli?: boolean; + /** + * Internal-only name for the target when generating skills. + * Used by generateMulti() so skill names are stable and composable. + */ + targetName?: string; +} + +function resolveSkillsOutputDir( + config: GraphQLSDKConfigTarget, + outputRoot: string, +): string { + const workspaceRoot = + findWorkspaceRoot(path.resolve(outputRoot)) ?? + findWorkspaceRoot(process.cwd()) ?? + process.cwd(); + + if (config.skillsPath) { + return path.isAbsolute(config.skillsPath) + ? config.skillsPath + : path.resolve(workspaceRoot, config.skillsPath); + } + + return path.resolve(workspaceRoot, 'skills'); } export async function generate( @@ -319,6 +343,8 @@ export async function generate( ...(customOperations.mutations ?? []), ]; const allMcpTools: McpTool[] = []; + const targetName = internalOptions?.targetName ?? 'default'; + const skillsToWrite: Array<{ path: string; content: string }> = []; if (runOrm) { if (docsConfig.readme) { @@ -333,8 +359,8 @@ export async function generate( allMcpTools.push(...getOrmMcpTools(tables, allCustomOps)); } if (docsConfig.skills) { - for (const skill of generateOrmSkills(tables, allCustomOps)) { - filesToWrite.push({ path: path.posix.join('orm', skill.fileName), content: skill.content }); + for (const skill of generateOrmSkills(tables, allCustomOps, targetName)) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } } @@ -352,8 +378,8 @@ export async function generate( allMcpTools.push(...getHooksMcpTools(tables, allCustomOps)); } if (docsConfig.skills) { - for (const skill of generateHooksSkills(tables, allCustomOps)) { - filesToWrite.push({ path: path.posix.join('hooks', skill.fileName), content: skill.content }); + for (const skill of generateHooksSkills(tables, allCustomOps, targetName)) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } } @@ -375,8 +401,8 @@ export async function generate( allMcpTools.push(...getCliMcpTools(tables, allCustomOps, toolName)); } if (docsConfig.skills) { - for (const skill of generateCliSkills(tables, allCustomOps, toolName)) { - filesToWrite.push({ path: path.posix.join('cli', skill.fileName), content: skill.content }); + for (const skill of generateCliSkills(tables, allCustomOps, toolName, targetName)) { + skillsToWrite.push({ path: skill.fileName, content: skill.content }); } } } @@ -418,6 +444,23 @@ export async function generate( }; } allFilesWritten.push(...(writeResult.filesWritten ?? [])); + + if (skillsToWrite.length > 0) { + const skillsOutputDir = resolveSkillsOutputDir(config, outputRoot); + const skillsWriteResult = await writeGeneratedFiles(skillsToWrite, skillsOutputDir, [], { + pruneStaleFiles: false, + }); + if (!skillsWriteResult.success) { + return { + success: false, + message: `Failed to write generated skill files: ${skillsWriteResult.errors?.join(', ')}`, + output: skillsOutputDir, + errors: skillsWriteResult.errors, + }; + } + allFilesWritten.push(...(skillsWriteResult.filesWritten ?? [])); + } + } const generators = [ @@ -652,7 +695,7 @@ export async function generateMulti( schemaOnly, schemaOnlyFilename: schemaOnly ? `${name}.graphql` : undefined, }, - useUnifiedCli ? { skipCli: true } : undefined, + useUnifiedCli ? { skipCli: true, targetName: name } : { targetName: name }, ); results.push({ name, result }); if (!result.success) { @@ -744,11 +787,6 @@ export async function generateMulti( if (docsConfig.mcp) { allMcpTools.push(...getMultiTargetCliMcpTools(docsInput)); } - if (docsConfig.skills) { - for (const skill of generateMultiTargetSkills(docsInput)) { - cliFilesToWrite.push({ path: path.posix.join('cli', skill.fileName), content: skill.content }); - } - } if (docsConfig.mcp && allMcpTools.length > 0) { const mcpFile = generateCombinedMcpConfig(allMcpTools, toolName); cliFilesToWrite.push({ path: path.posix.join('cli', mcpFile.fileName), content: mcpFile.content }); @@ -756,6 +794,24 @@ export async function generateMulti( const { writeGeneratedFiles: writeFiles } = await import('./output'); await writeFiles(cliFilesToWrite, '.', [], { pruneStaleFiles: false }); + + if (docsConfig.skills) { + const cliSkillsToWrite = generateMultiTargetSkills(docsInput).map((skill) => ({ + path: skill.fileName, + content: skill.content, + })); + + const firstTargetResolved = getConfigOptions({ + ...(firstTargetConfig ?? {}), + ...(cliOverrides ?? {}), + }); + const skillsOutputDir = resolveSkillsOutputDir( + firstTargetResolved, + firstTargetResolved.output, + ); + await writeFiles(cliSkillsToWrite, skillsOutputDir, [], { pruneStaleFiles: false }); + + } } // Generate root-root README if multi-target diff --git a/graphql/codegen/src/core/workspace.ts b/graphql/codegen/src/core/workspace.ts new file mode 100644 index 000000000..27f1df745 --- /dev/null +++ b/graphql/codegen/src/core/workspace.ts @@ -0,0 +1,100 @@ +/** + * Workspace detection utilities + * + * Finds the root of a workspace by walking up directories looking for + * workspace markers (pnpm-workspace.yaml, lerna.json, package.json with workspaces). + * Falls back to the nearest package.json directory. + * + * Inspired by @pgpmjs/env walkUp / resolvePnpmWorkspace patterns. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve, parse as parsePath } from 'node:path'; + +/** + * Walk up directories from startDir looking for a file. + * Returns the directory containing the file, or null if not found. + */ +function walkUp(startDir: string, filename: string): string | null { + let currentDir = resolve(startDir); + + while (currentDir) { + const targetPath = resolve(currentDir, filename); + if (existsSync(targetPath)) { + return currentDir; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return null; +} + +/** + * Find the first pnpm workspace root by looking for pnpm-workspace.yaml + */ +function findPnpmWorkspace(cwd: string): string | null { + return walkUp(cwd, 'pnpm-workspace.yaml'); +} + +/** + * Find the first lerna workspace root by looking for lerna.json + */ +function findLernaWorkspace(cwd: string): string | null { + return walkUp(cwd, 'lerna.json'); +} + +/** + * Find the first npm/yarn workspace root by looking for package.json with workspaces field + */ +function findNpmWorkspace(cwd: string): string | null { + let currentDir = resolve(cwd); + const root = parsePath(currentDir).root; + + while (currentDir !== root) { + const packageJsonPath = resolve(currentDir, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + if (packageJson.workspaces) { + return currentDir; + } + } catch { + // Ignore JSON parse errors + } + } + currentDir = dirname(currentDir); + } + return null; +} + +/** + * Find the nearest package.json directory (fallback) + */ +function findPackageRoot(cwd: string): string | null { + return walkUp(cwd, 'package.json'); +} + +/** + * Find the workspace root directory. + * + * Search order: + * 1. pnpm-workspace.yaml (pnpm workspaces) + * 2. lerna.json (lerna workspaces) + * 3. package.json with "workspaces" field (npm/yarn workspaces) + * 4. Nearest package.json (fallback for non-workspace projects) + * + * @param cwd - Starting directory to search from (defaults to process.cwd()) + * @returns The workspace root path, or null if nothing found + */ +export function findWorkspaceRoot(cwd: string = process.cwd()): string | null { + return ( + findPnpmWorkspace(cwd) ?? + findLernaWorkspace(cwd) ?? + findNpmWorkspace(cwd) ?? + findPackageRoot(cwd) + ); +} diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 0f2a3a16c..d3a5a90c1 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -165,9 +165,9 @@ export interface DocsConfig { mcp?: boolean; /** - * Generate skills/ directory — per-command .md skill files - * Each command gets its own skill file with description, usage, and examples - * Compatible with Devin and similar agent skill systems + * Generate skills/ directory — per-entity SKILL.md files with YAML frontmatter. + * Skills are written to the workspace root skills/ directory (not nested in output). + * Uses composable naming: orm-{target}-{entity}, hooks-{target}-{entity}, cli-{target}-{entity}. * @default false */ skills?: boolean; @@ -377,10 +377,19 @@ export interface GraphQLSDKConfigTarget { * Controls which doc formats are generated alongside code for each generator target. * Applied globally to all enabled generators (ORM, React Query, CLI). * Set to `true` to enable all formats, or configure individually. - * @default { readme: true, agents: true, mcp: false, skills: false } + * @default { readme: true, agents: true, mcp: false } */ docs?: DocsConfig | boolean; + /** + * Custom path for generated skill files. + * When set, skills are written to this directory. + * When undefined (default), skills are written to {workspaceRoot}/skills/ + * where workspaceRoot is auto-detected by walking up from the output directory + * looking for pnpm-workspace.yaml, lerna.json, or package.json with workspaces. + */ + skillsPath?: string; + /** * Query key generation configuration * Controls how query keys are structured for cache management diff --git a/sdk/constructive-react/scripts/generate-react.ts b/sdk/constructive-react/scripts/generate-react.ts index eb0382fa5..2a7ceb169 100644 --- a/sdk/constructive-react/scripts/generate-react.ts +++ b/sdk/constructive-react/scripts/generate-react.ts @@ -21,7 +21,7 @@ async function main() { docs: { agents: false, mcp: false, - skills: true + skills: true, } }; diff --git a/sdk/constructive-sdk/scripts/generate-sdk.ts b/sdk/constructive-sdk/scripts/generate-sdk.ts index a81bd91bd..249823e83 100644 --- a/sdk/constructive-sdk/scripts/generate-sdk.ts +++ b/sdk/constructive-sdk/scripts/generate-sdk.ts @@ -21,7 +21,7 @@ async function main() { docs: { agents: false, mcp: false, - skills: true + skills: true, } };