From 71515c70ac411634a8d363263f5f34288eb55749 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sat, 6 Dec 2025 23:26:29 +0900 Subject: [PATCH 01/30] feat(cli): Add --generate-skill option for Claude Agent Skills output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Claude Agent Skills format output support for Repomix. This feature was requested in issue #952 to enable seamless integration with Claude Code's skill system. New CLI option: repomix --generate-skill Output structure: .claude/skills// ├── SKILL.md # Entry point with YAML frontmatter └── references/ └── codebase.md # Packed codebase in Markdown format Features: - Skill name auto-converts to kebab-case (max 64 chars) - SKILL.md includes usage guidance for reading the codebase - Incompatible with --stdout and --copy options - MCP tool `generate_skill` for programmatic access New files: - src/core/output/skillUtils.ts - src/core/output/outputStyles/skillStyle.ts - src/core/packager/writeSkillOutput.ts - src/mcp/tools/generateSkillTool.ts Closes #952 --- src/cli/actions/defaultAction.ts | 19 +++ src/cli/cliReport.ts | 9 +- src/cli/cliRun.ts | 6 + src/cli/types.ts | 3 + src/config/configLoad.ts | 2 + src/config/configSchema.ts | 3 +- src/core/output/outputGenerate.ts | 69 +++++++++++ src/core/output/outputStyles/skillStyle.ts | 57 +++++++++ src/core/output/skillUtils.ts | 57 +++++++++ src/core/packager.ts | 62 ++++++++-- src/core/packager/writeSkillOutput.ts | 40 +++++++ src/mcp/mcpServer.ts | 3 + src/mcp/tools/generateSkillTool.ts | 93 +++++++++++++++ .../output/outputStyles/skillStyle.test.ts | 78 +++++++++++++ tests/core/output/skillUtils.test.ts | 110 ++++++++++++++++++ tests/core/packager/writeSkillOutput.test.ts | 64 ++++++++++ 16 files changed, 666 insertions(+), 9 deletions(-) create mode 100644 src/core/output/outputStyles/skillStyle.ts create mode 100644 src/core/output/skillUtils.ts create mode 100644 src/core/packager/writeSkillOutput.ts create mode 100644 src/mcp/tools/generateSkillTool.ts create mode 100644 tests/core/output/outputStyles/skillStyle.test.ts create mode 100644 tests/core/output/skillUtils.test.ts create mode 100644 tests/core/packager/writeSkillOutput.test.ts diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index bf606c035..dece5a7a6 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -49,6 +49,20 @@ export const runDefaultAction = async ( const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); logger.trace('Merged config:', config); + // Validate skill generation options + if (config.generateSkill) { + if (config.output.stdout) { + throw new RepomixError( + '--generate-skill cannot be used with --stdout. Skill output requires writing to filesystem.', + ); + } + if (config.output.copyToClipboard) { + throw new RepomixError( + '--generate-skill cannot be used with --copy. Skill output is a directory and cannot be copied to clipboard.', + ); + } + } + // Handle stdin processing in main process (before worker creation) // This is necessary because child_process workers don't inherit stdin let stdinFilePaths: string[] | undefined; @@ -287,6 +301,11 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => { }; } + // Skill generation + if (options.generateSkill) { + cliConfig.generateSkill = options.generateSkill; + } + try { return repomixConfigCliSchema.parse(cliConfig); } catch (error) { diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index 44b9d16b4..d795813a2 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -65,7 +65,14 @@ export const reportSummary = (packResult: PackResult, config: RepomixConfigMerge logger.log(`${pc.white(' Total Files:')} ${pc.white(packResult.totalFiles.toLocaleString())} files`); logger.log(`${pc.white(' Total Tokens:')} ${pc.white(packResult.totalTokens.toLocaleString())} tokens`); logger.log(`${pc.white(' Total Chars:')} ${pc.white(packResult.totalCharacters.toLocaleString())} chars`); - logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); + + // Show skill output path or regular output path + if (config.generateSkill) { + const skillPath = `.claude/skills/${config.generateSkill}/`; + logger.log(`${pc.white(' Output:')} ${pc.white(skillPath)} ${pc.dim('(skill directory)')}`); + } else { + logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); + } logger.log(`${pc.white(' Security:')} ${pc.white(securityCheckMessage)}`); if (config.output.git?.includeDiffs) { diff --git a/src/cli/cliRun.ts b/src/cli/cliRun.ts index 4e5c4e607..00a04014d 100644 --- a/src/cli/cliRun.ts +++ b/src/cli/cliRun.ts @@ -168,6 +168,12 @@ export const run = async () => { // MCP .optionsGroup('MCP') .option('--mcp', 'Run as Model Context Protocol server for AI tool integration') + // Skill Generation + .optionsGroup('Skill Generation (Experimental)') + .option( + '--generate-skill ', + 'Generate Claude Agent Skills format output to .claude/skills// directory', + ) .action(commanderActionEndpoint); // Custom error handling function diff --git a/src/cli/types.ts b/src/cli/types.ts index de9ad6410..ba25be776 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -55,6 +55,9 @@ export interface CliOptions extends OptionValues { // MCP mcp?: boolean; + // Skill Generation + generateSkill?: string; + // Other Options topFilesLen?: number; verbose?: boolean; diff --git a/src/config/configLoad.ts b/src/config/configLoad.ts index 1f6066ae1..3770a7315 100644 --- a/src/config/configLoad.ts +++ b/src/config/configLoad.ts @@ -230,6 +230,8 @@ export const mergeConfigs = ( ...fileConfig.tokenCount, ...cliConfig.tokenCount, }, + // Skill generation (CLI only) + ...(cliConfig.generateSkill && { generateSkill: cliConfig.generateSkill }), }; try { diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 88511bdf2..775d43170 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -130,7 +130,7 @@ export const repomixConfigDefaultSchema = z.object({ // File-specific schema. Add options for file path and style export const repomixConfigFileSchema = repomixConfigBaseSchema; -// CLI-specific schema. Add options for standard output mode +// CLI-specific schema. Add options for standard output mode and skill generation export const repomixConfigCliSchema = repomixConfigBaseSchema.and( z.object({ output: z @@ -138,6 +138,7 @@ export const repomixConfigCliSchema = repomixConfigBaseSchema.and( stdout: z.boolean().optional(), }) .optional(), + generateSkill: z.string().optional(), }), ); diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index b01bc3249..627cf1012 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -21,7 +21,9 @@ import { } from './outputStyleDecorate.js'; import { getMarkdownTemplate } from './outputStyles/markdownStyle.js'; import { getPlainTemplate } from './outputStyles/plainStyle.js'; +import { generateSkillMd } from './outputStyles/skillStyle.js'; import { getXmlTemplate } from './outputStyles/xmlStyle.js'; +import { generateProjectName, generateSkillDescription, validateSkillName } from './skillUtils.js'; const calculateMarkdownDelimiter = (files: ReadonlyArray): string => { const maxBackticks = files @@ -349,3 +351,70 @@ export const buildOutputGeneratorContext = async ( gitLogResult, }; }; + +/** + * Result of skill output generation + */ +export interface SkillOutputResult { + skillMd: string; + codebaseMd: string; +} + +/** + * Generates Claude Agent Skills format output. + * Creates SKILL.md and codebase.md (Markdown fixed). + */ +export const generateSkillOutput = async ( + skillName: string, + rootDirs: string[], + config: RepomixConfigMerged, + processedFiles: ProcessedFile[], + allFilePaths: string[], + totalTokens: number, + gitDiffResult: GitDiffResult | undefined = undefined, + gitLogResult: GitLogResult | undefined = undefined, + deps = { + generateOutput, + }, +): Promise => { + // Validate and normalize skill name + const normalizedSkillName = validateSkillName(skillName); + + // Generate project name from root directories + const projectName = generateProjectName(rootDirs); + + // Generate skill description + const skillDescription = generateSkillDescription(normalizedSkillName, projectName); + + // Generate SKILL.md content + const skillMd = generateSkillMd({ + skillName: normalizedSkillName, + skillDescription, + projectName, + totalFiles: processedFiles.length, + totalTokens, + }); + + // Generate codebase.md using markdown style (fixed) + const markdownConfig: RepomixConfigMerged = { + ...config, + output: { + ...config.output, + style: 'markdown', + }, + }; + + const codebaseMd = await deps.generateOutput( + rootDirs, + markdownConfig, + processedFiles, + allFilePaths, + gitDiffResult, + gitLogResult, + ); + + return { + skillMd, + codebaseMd, + }; +}; diff --git a/src/core/output/outputStyles/skillStyle.ts b/src/core/output/outputStyles/skillStyle.ts new file mode 100644 index 000000000..b10ee8c13 --- /dev/null +++ b/src/core/output/outputStyles/skillStyle.ts @@ -0,0 +1,57 @@ +import Handlebars from 'handlebars'; + +export interface SkillRenderContext { + skillName: string; + skillDescription: string; + projectName: string; + totalFiles: number; + totalTokens: number; +} + +/** + * Returns the Handlebars template for SKILL.md. + * Following Claude Agent Skills best practices for progressive disclosure. + */ +export const getSkillTemplate = (): string => { + return /* md */ `--- +name: {{{skillName}}} +description: {{{skillDescription}}} +--- + +# {{{projectName}}} Codebase Reference + +This skill provides reference to the {{{projectName}}} codebase. + +## Statistics + +- Total Files: {{{totalFiles}}} +- Total Tokens: {{{totalTokens}}} + +## How to Use + +The complete codebase is available in \`references/codebase.md\`. + +### Reading the Codebase + +To understand the project structure, start by reading the "Directory Structure" section in the codebase file. + +For specific code details: +1. Look at the "Files" section to find the file you need +2. Each file includes its path and content +3. Use grep patterns to search for specific functions or classes + +Use this when you need to: +- Understand the project structure +- Find implementation details +- Reference code patterns +`; +}; + +/** + * Generates the SKILL.md content from the given context. + */ +export const generateSkillMd = (context: SkillRenderContext): string => { + const template = getSkillTemplate(); + const compiledTemplate = Handlebars.compile(template); + return `${compiledTemplate(context).trim()}\n`; +}; diff --git a/src/core/output/skillUtils.ts b/src/core/output/skillUtils.ts new file mode 100644 index 000000000..3f022a985 --- /dev/null +++ b/src/core/output/skillUtils.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +const SKILL_NAME_MAX_LENGTH = 64; +const SKILL_DESCRIPTION_MAX_LENGTH = 1024; + +/** + * Converts a string to kebab-case. + * Handles PascalCase, camelCase, snake_case, and spaces. + */ +export const toKebabCase = (str: string): string => { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') // Handle PascalCase/camelCase + .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens + .replace(/[^a-z0-9-]/gi, '') // Remove invalid characters + .toLowerCase() + .replace(/-+/g, '-') // Collapse multiple hyphens + .replace(/^-|-$/g, ''); // Trim leading/trailing hyphens +}; + +/** + * Validates and normalizes a skill name. + * Converts to kebab-case and truncates to 64 characters. + */ +export const validateSkillName = (name: string): string => { + const kebabName = toKebabCase(name); + + if (kebabName.length === 0) { + throw new Error('Skill name cannot be empty after normalization'); + } + + return kebabName.substring(0, SKILL_NAME_MAX_LENGTH); +}; + +/** + * Generates a human-readable project name from root directories. + * Uses the first directory's basename, converted to Title Case. + */ +export const generateProjectName = (rootDirs: string[]): string => { + const primaryDir = rootDirs[0] || '.'; + const dirName = path.basename(path.resolve(primaryDir)); + + // Convert kebab-case or snake_case to Title Case + return dirName + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +}; + +/** + * Generates a skill description following Claude Agent Skills best practices. + * Description includes what the skill does and when to use it. + */ +export const generateSkillDescription = (_skillName: string, projectName: string): string => { + const description = `Reference codebase for ${projectName}. Use this skill when you need to understand the structure, implementation patterns, or code details of the ${projectName} project.`; + + return description.substring(0, SKILL_DESCRIPTION_MAX_LENGTH); +}; diff --git a/src/core/packager.ts b/src/core/packager.ts index ad980193f..35f9b0ab8 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -9,9 +9,10 @@ import type { ProcessedFile } from './file/fileTypes.js'; import { getGitDiffs } from './git/gitDiffHandle.js'; import { getGitLogs } from './git/gitLogHandle.js'; import { calculateMetrics } from './metrics/calculateMetrics.js'; -import { generateOutput } from './output/outputGenerate.js'; +import { generateOutput, generateSkillOutput } from './output/outputGenerate.js'; import { copyToClipboardIfEnabled } from './packager/copyToClipboardIfEnabled.js'; import { writeOutputToDisk } from './packager/writeOutputToDisk.js'; +import { writeSkillOutput } from './packager/writeSkillOutput.js'; import type { SuspiciousFileResult } from './security/securityCheck.js'; import { validateFileSafety } from './security/validateFileSafety.js'; @@ -36,8 +37,10 @@ const defaultDeps = { collectFiles, processFiles, generateOutput, + generateSkillOutput, validateFileSafety, writeOutputToDisk, + writeSkillOutput, copyToClipboardIfEnabled, calculateMetrics, sortPaths, @@ -117,14 +120,59 @@ export const pack = async ( ); progressCallback('Generating output...'); - const output = await withMemoryLogging('Generate Output', () => - deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), - ); - progressCallback('Writing output file...'); - await withMemoryLogging('Write Output', () => deps.writeOutputToDisk(output, config)); + let output: string; + + // Check if skill generation is requested + if (config.generateSkill) { + // Generate codebaseMd first using markdown style + const markdownConfig: RepomixConfigMerged = { + ...config, + output: { + ...config.output, + style: 'markdown', + }, + }; + const codebaseMd = await withMemoryLogging('Generate Codebase Markdown', () => + deps.generateOutput(rootDirs, markdownConfig, processedFiles, allFilePaths, gitDiffResult, gitLogResult), + ); + + // Calculate metrics from codebaseMd to get accurate token count + const codebaseMetrics = await withMemoryLogging('Calculate Codebase Metrics', () => + deps.calculateMetrics(processedFiles, codebaseMd, progressCallback, config, gitDiffResult, gitLogResult), + ); + + // Generate skill output with accurate token count + const skillOutput = await withMemoryLogging('Generate Skill Output', () => + deps.generateSkillOutput( + config.generateSkill as string, + rootDirs, + config, + processedFiles, + allFilePaths, + codebaseMetrics.totalTokens, + gitDiffResult, + gitLogResult, + ), + ); + + progressCallback('Writing skill output...'); + await withMemoryLogging('Write Skill Output', () => + deps.writeSkillOutput(skillOutput, config.generateSkill as string, config.cwd), + ); + + // Use codebaseMd for final metrics + output = codebaseMd; + } else { + output = await withMemoryLogging('Generate Output', () => + deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), + ); + + progressCallback('Writing output file...'); + await withMemoryLogging('Write Output', () => deps.writeOutputToDisk(output, config)); - await deps.copyToClipboardIfEnabled(output, progressCallback, config); + await deps.copyToClipboardIfEnabled(output, progressCallback, config); + } const metrics = await withMemoryLogging('Calculate Metrics', () => deps.calculateMetrics(processedFiles, output, progressCallback, config, gitDiffResult, gitLogResult), diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts new file mode 100644 index 000000000..39c8d67fb --- /dev/null +++ b/src/core/packager/writeSkillOutput.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { SkillOutputResult } from '../output/outputGenerate.js'; + +const SKILL_DIR_NAME = '.claude/skills'; + +/** + * Writes skill output to the filesystem. + * Creates the directory structure: + * .claude/skills// + * ├── SKILL.md + * └── references/ + * └── codebase.md + */ +export const writeSkillOutput = async ( + output: SkillOutputResult, + skillName: string, + cwd: string, + deps = { + mkdir: fs.mkdir, + writeFile: fs.writeFile, + }, +): Promise => { + const skillDir = path.join(cwd, SKILL_DIR_NAME, skillName); + const referencesDir = path.join(skillDir, 'references'); + + // Create directories + await deps.mkdir(skillDir, { recursive: true }); + await deps.mkdir(referencesDir, { recursive: true }); + + // Write SKILL.md + const skillMdPath = path.join(skillDir, 'SKILL.md'); + await deps.writeFile(skillMdPath, output.skillMd, 'utf-8'); + + // Write references/codebase.md + const codebaseMdPath = path.join(referencesDir, 'codebase.md'); + await deps.writeFile(codebaseMdPath, output.codebaseMd, 'utf-8'); + + return skillDir; +}; diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index b820e439a..d198a405b 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -6,6 +6,7 @@ import { registerPackRemoteRepositoryPrompt } from './prompts/packRemoteReposito import { registerAttachPackedOutputTool } from './tools/attachPackedOutputTool.js'; import { registerFileSystemReadDirectoryTool } from './tools/fileSystemReadDirectoryTool.js'; import { registerFileSystemReadFileTool } from './tools/fileSystemReadFileTool.js'; +import { registerGenerateSkillTool } from './tools/generateSkillTool.js'; import { registerGrepRepomixOutputTool } from './tools/grepRepomixOutputTool.js'; import { registerPackCodebaseTool } from './tools/packCodebaseTool.js'; import { registerPackRemoteRepositoryTool } from './tools/packRemoteRepositoryTool.js'; @@ -17,6 +18,7 @@ import { registerReadRepomixOutputTool } from './tools/readRepomixOutputTool.js' const MCP_SERVER_INSTRUCTIONS = 'Repomix MCP Server provides AI-optimized codebase analysis tools. ' + 'Use pack_codebase or pack_remote_repository to consolidate code into a single XML file, ' + + 'use generate_skill to create Claude Agent Skills from codebases, ' + 'use attach_packed_output to work with existing packed outputs, ' + 'then read_repomix_output and grep_repomix_output to analyze it. ' + 'Perfect for code reviews, documentation generation, bug investigation, GitHub repository analysis, and understanding large codebases. ' + @@ -39,6 +41,7 @@ export const createMcpServer = async () => { // Register the tools registerPackCodebaseTool(mcpServer); registerPackRemoteRepositoryTool(mcpServer); + registerGenerateSkillTool(mcpServer); registerAttachPackedOutputTool(mcpServer); registerReadRepomixOutputTool(mcpServer); registerGrepRepomixOutputTool(mcpServer); diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts new file mode 100644 index 000000000..5fd51c4d4 --- /dev/null +++ b/src/mcp/tools/generateSkillTool.ts @@ -0,0 +1,93 @@ +import path from 'node:path'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { runCli } from '../../cli/cliRun.js'; +import type { CliOptions } from '../../cli/types.js'; +import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, convertErrorToJson } from './mcpToolRuntime.js'; + +const generateSkillInputSchema = z.object({ + directory: z.string().describe('Directory to pack (Absolute path)'), + skillName: z + .string() + .describe( + 'Name of the skill to generate (kebab-case, max 64 chars). Will be normalized if not in kebab-case. Used for the skill directory name and SKILL.md metadata.', + ), + compress: z + .boolean() + .default(false) + .describe( + 'Enable Tree-sitter compression to extract essential code signatures and structure while removing implementation details (default: false).', + ), + includePatterns: z + .string() + .optional() + .describe( + 'Specify files to include using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "**/*.{js,ts}", "src/**,docs/**").', + ), + ignorePatterns: z + .string() + .optional() + .describe( + 'Specify additional files to exclude using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "test/**,*.spec.js").', + ), +}); + +const generateSkillOutputSchema = z.object({ + skillPath: z.string().describe('Path to the generated skill directory'), + skillName: z.string().describe('Normalized name of the generated skill'), + totalFiles: z.number().describe('Total number of files processed'), + totalTokens: z.number().describe('Total token count of the content'), + description: z.string().describe('Human-readable description of the skill generation results'), +}); + +export const registerGenerateSkillTool = (mcpServer: McpServer) => { + mcpServer.registerTool( + 'generate_skill', + { + title: 'Generate Claude Agent Skill', + description: + 'Generate a Claude Agent Skill from a local code directory. Creates a skill package at .claude/skills// containing SKILL.md (entry point with metadata) and references/codebase.md (the packed codebase in Markdown format). This skill can be used by Claude to understand and reference the codebase.', + inputSchema: generateSkillInputSchema, + outputSchema: generateSkillOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ directory, skillName, compress, includePatterns, ignorePatterns }): Promise => { + try { + const cliOptions = { + generateSkill: skillName, + compress, + include: includePatterns, + ignore: ignorePatterns, + securityCheck: true, + quiet: true, + } as CliOptions; + + const result = await runCli(['.'], directory, cliOptions); + if (!result) { + return buildMcpToolErrorResponse({ + errorMessage: 'Failed to generate skill', + }); + } + + const { packResult, config } = result; + const skillPath = path.join(directory, '.claude', 'skills', config.generateSkill || skillName); + + return buildMcpToolSuccessResponse({ + skillPath, + skillName: config.generateSkill || skillName, + totalFiles: packResult.totalFiles, + totalTokens: packResult.totalTokens, + description: `Successfully generated Claude Agent Skill at ${skillPath}. The skill contains ${packResult.totalFiles} files with ${packResult.totalTokens.toLocaleString()} tokens.`, + } satisfies z.infer); + } catch (error) { + return buildMcpToolErrorResponse(convertErrorToJson(error)); + } + }, + ); +}; diff --git a/tests/core/output/outputStyles/skillStyle.test.ts b/tests/core/output/outputStyles/skillStyle.test.ts new file mode 100644 index 000000000..701dde06c --- /dev/null +++ b/tests/core/output/outputStyles/skillStyle.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'vitest'; +import { generateSkillMd, getSkillTemplate } from '../../../../src/core/output/outputStyles/skillStyle.js'; + +describe('skillStyle', () => { + describe('getSkillTemplate', () => { + test('should return valid SKILL.md template', () => { + const template = getSkillTemplate(); + expect(template).toContain('---'); + expect(template).toContain('name:'); + expect(template).toContain('description:'); + expect(template).toContain('# '); + expect(template).toContain('references/codebase.md'); + }); + + test('should include statistics section', () => { + const template = getSkillTemplate(); + expect(template).toContain('## Statistics'); + expect(template).toContain('Total Files'); + expect(template).toContain('Total Tokens'); + }); + + test('should include how to use section', () => { + const template = getSkillTemplate(); + expect(template).toContain('## How to Use'); + expect(template).toContain('Reading the Codebase'); + }); + }); + + describe('generateSkillMd', () => { + test('should generate SKILL.md with all fields', () => { + const context = { + skillName: 'my-project-skill', + skillDescription: 'Reference codebase for My Project.', + projectName: 'My Project', + totalFiles: 42, + totalTokens: 12345, + }; + + const result = generateSkillMd(context); + + // Check YAML frontmatter + expect(result).toContain('---'); + expect(result).toContain('name: my-project-skill'); + expect(result).toContain('description: Reference codebase for My Project.'); + + // Check content + expect(result).toContain('# My Project Codebase Reference'); + expect(result).toContain('Total Files: 42'); + expect(result).toContain('Total Tokens: 12345'); + }); + + test('should end with newline', () => { + const context = { + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalTokens: 100, + }; + + const result = generateSkillMd(context); + expect(result.endsWith('\n')).toBe(true); + }); + + test('should include reference to codebase.md', () => { + const context = { + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalTokens: 100, + }; + + const result = generateSkillMd(context); + expect(result).toContain('`references/codebase.md`'); + }); + }); +}); diff --git a/tests/core/output/skillUtils.test.ts b/tests/core/output/skillUtils.test.ts new file mode 100644 index 000000000..b5336d646 --- /dev/null +++ b/tests/core/output/skillUtils.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest'; +import { + generateProjectName, + generateSkillDescription, + toKebabCase, + validateSkillName, +} from '../../../src/core/output/skillUtils.js'; + +describe('skillUtils', () => { + describe('toKebabCase', () => { + test('should convert PascalCase to kebab-case', () => { + expect(toKebabCase('MyProjectName')).toBe('my-project-name'); + }); + + test('should convert camelCase to kebab-case', () => { + expect(toKebabCase('myProjectName')).toBe('my-project-name'); + }); + + test('should convert snake_case to kebab-case', () => { + expect(toKebabCase('my_project_name')).toBe('my-project-name'); + }); + + test('should convert spaces to hyphens', () => { + expect(toKebabCase('my project name')).toBe('my-project-name'); + }); + + test('should remove invalid characters', () => { + expect(toKebabCase('my@project#name!')).toBe('myprojectname'); + }); + + test('should collapse multiple hyphens', () => { + expect(toKebabCase('my--project--name')).toBe('my-project-name'); + }); + + test('should trim leading and trailing hyphens', () => { + expect(toKebabCase('-my-project-name-')).toBe('my-project-name'); + }); + + test('should handle already kebab-case strings', () => { + expect(toKebabCase('my-project-name')).toBe('my-project-name'); + }); + + test('should handle empty string', () => { + expect(toKebabCase('')).toBe(''); + }); + + test('should handle mixed case with numbers', () => { + // Numbers don't trigger hyphen insertion (only lowercase-to-uppercase transitions do) + expect(toKebabCase('MyProject123Name')).toBe('my-project123name'); + }); + }); + + describe('validateSkillName', () => { + test('should return kebab-case name', () => { + expect(validateSkillName('MyProject')).toBe('my-project'); + }); + + test('should truncate to 64 characters', () => { + const longName = 'a'.repeat(100); + expect(validateSkillName(longName).length).toBe(64); + }); + + test('should throw error for empty name after normalization', () => { + expect(() => validateSkillName('!@#$%')).toThrow('Skill name cannot be empty after normalization'); + }); + + test('should handle valid kebab-case names', () => { + expect(validateSkillName('my-valid-skill-name')).toBe('my-valid-skill-name'); + }); + }); + + describe('generateProjectName', () => { + test('should convert directory name to title case', () => { + expect(generateProjectName(['my-project'])).toBe('My Project'); + }); + + test('should handle snake_case directory names', () => { + expect(generateProjectName(['my_project_name'])).toBe('My Project Name'); + }); + + test('should use first directory when multiple provided', () => { + expect(generateProjectName(['first-project', 'second-project'])).toBe('First Project'); + }); + + test('should handle current directory', () => { + // This depends on the actual directory name, so we just check it returns something + const result = generateProjectName(['.']); + expect(result).toBeTruthy(); + }); + }); + + describe('generateSkillDescription', () => { + test('should generate description with skill and project names', () => { + const description = generateSkillDescription('my-skill', 'My Project'); + expect(description).toContain('My Project'); + expect(description).toContain('Reference codebase'); + }); + + test('should truncate to 1024 characters', () => { + const longProjectName = 'A'.repeat(1000); + const description = generateSkillDescription('my-skill', longProjectName); + expect(description.length).toBeLessThanOrEqual(1024); + }); + + test('should include usage guidance', () => { + const description = generateSkillDescription('my-skill', 'My Project'); + expect(description).toContain('Use this skill when'); + }); + }); +}); diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/packager/writeSkillOutput.test.ts new file mode 100644 index 000000000..ed1dc3d8c --- /dev/null +++ b/tests/core/packager/writeSkillOutput.test.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { describe, expect, test, vi } from 'vitest'; +import { writeSkillOutput } from '../../../src/core/packager/writeSkillOutput.js'; + +describe('writeSkillOutput', () => { + test('should create skill directory structure and write files', async () => { + const mockMkdir = vi.fn().mockResolvedValue(undefined); + const mockWriteFile = vi.fn().mockResolvedValue(undefined); + + const output = { + skillMd: '---\nname: test-skill\n---\n# Test Skill', + codebaseMd: '# Codebase\n\nFile contents here', + }; + + const skillName = 'test-skill'; + const cwd = '/test/project'; + + const result = await writeSkillOutput(output, skillName, cwd, { + mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, + writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, + }); + + // Check directories were created + expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', skillName), { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', skillName, 'references'), { + recursive: true, + }); + + // Check files were written + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'SKILL.md'), + output.skillMd, + 'utf-8', + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'references', 'codebase.md'), + output.codebaseMd, + 'utf-8', + ); + + // Check return value + expect(result).toBe(path.join(cwd, '.claude/skills', skillName)); + }); + + test('should handle skill names with special characters', async () => { + const mockMkdir = vi.fn().mockResolvedValue(undefined); + const mockWriteFile = vi.fn().mockResolvedValue(undefined); + + const output = { + skillMd: '# Skill', + codebaseMd: '# Codebase', + }; + + const skillName = 'my-special-skill'; + const cwd = '/test/project'; + + await writeSkillOutput(output, skillName, cwd, { + mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, + writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, + }); + + expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', 'my-special-skill'), { recursive: true }); + }); +}); From df5647c6825445e870401ce22acf0a6b3ee8a33c Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 14:16:53 +0900 Subject: [PATCH 02/30] feat(skill): Enhance skill output with split files and auto-naming Improve the Claude Agent Skills generation feature: - Rename CLI option from --generate-skill to --skill-generate [name] - Make skill name optional with auto-generation (repomix-reference-) - Split skill output into multiple reference files: - summary.md: Purpose, format description, and notes - structure.md: Directory tree view - files.md: Complete file contents - git-diffs.md: Uncommitted changes (optional) - git-logs.md: Recent commit history (optional) - Move skill-related code to src/core/output/skill/ folder - Extract shared language mapping to outputStyleUtils.ts - Add 100+ language mappings for syntax highlighting --- src/cli/actions/defaultAction.ts | 18 +- src/cli/actions/remoteAction.ts | 51 +++- src/cli/cliReport.ts | 9 +- src/cli/cliRun.ts | 4 +- src/cli/types.ts | 3 +- src/config/configLoad.ts | 2 +- src/config/configSchema.ts | 4 +- src/core/output/outputGenerate.ts | 68 ++++-- src/core/output/outputStyleUtils.ts | 223 ++++++++++++++++++ src/core/output/outputStyles/markdownStyle.ts | 107 +-------- src/core/output/outputStyles/skillStyle.ts | 57 ----- .../output/skill/skillSectionGenerators.ts | 127 ++++++++++ src/core/output/skill/skillStyle.ts | 78 ++++++ src/core/output/{ => skill}/skillUtils.ts | 48 ++++ src/core/packager.ts | 61 +++-- src/core/packager/writeSkillOutput.ts | 22 +- src/mcp/tools/generateSkillTool.ts | 15 +- .../output/outputStyles/markdownStyle.test.ts | 4 +- .../skillStyle.test.ts | 61 ++++- .../output/{ => skill}/skillUtils.test.ts | 2 +- tests/core/packager/writeSkillOutput.test.ts | 69 +++++- 21 files changed, 789 insertions(+), 244 deletions(-) create mode 100644 src/core/output/outputStyleUtils.ts delete mode 100644 src/core/output/outputStyles/skillStyle.ts create mode 100644 src/core/output/skill/skillSectionGenerators.ts create mode 100644 src/core/output/skill/skillStyle.ts rename src/core/output/{ => skill}/skillUtils.ts (58%) rename tests/core/output/{outputStyles => skill}/skillStyle.test.ts (54%) rename tests/core/output/{ => skill}/skillUtils.test.ts (98%) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index dece5a7a6..5e04c4e86 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -46,19 +46,25 @@ export const runDefaultAction = async ( logger.trace('CLI config:', cliConfig); // Merge default, file, and CLI configs - const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); + let config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); + + // Add remoteUrl if provided (for skill name auto-generation in remote mode) + if (cliOptions.remoteUrl) { + config = { ...config, remoteUrl: cliOptions.remoteUrl }; + } + logger.trace('Merged config:', config); // Validate skill generation options - if (config.generateSkill) { + if (config.skillGenerate !== undefined) { if (config.output.stdout) { throw new RepomixError( - '--generate-skill cannot be used with --stdout. Skill output requires writing to filesystem.', + '--skill-generate cannot be used with --stdout. Skill output requires writing to filesystem.', ); } if (config.output.copyToClipboard) { throw new RepomixError( - '--generate-skill cannot be used with --copy. Skill output is a directory and cannot be copied to clipboard.', + '--skill-generate cannot be used with --copy. Skill output is a directory and cannot be copied to clipboard.', ); } } @@ -302,8 +308,8 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => { } // Skill generation - if (options.generateSkill) { - cliConfig.generateSkill = options.generateSkill; + if (options.skillGenerate !== undefined) { + cliConfig.skillGenerate = options.skillGenerate; } try { diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index ade5e7a49..c3efa7adc 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -89,13 +89,20 @@ export const runRemoteAction = async ( } // Run the default action on the downloaded/cloned repository - result = await deps.runDefaultAction([tempDirPath], tempDirPath, cliOptions); + // Pass the remote URL for skill name auto-generation + const optionsWithRemoteUrl = { ...cliOptions, remoteUrl: repoUrl }; + result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithRemoteUrl); - // Copy output file only when not in stdout mode - // In stdout mode, output is written directly to stdout without creating a file, - // so attempting to copy a non-existent file would cause an error and exit code 1 + // Copy output to current directory + // Skip copy for stdout mode (output goes directly to stdout) + // For skill generation, copy the skill directory instead of a single file if (!cliOptions.stdout) { - await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath); + if (result.config.skillGenerate !== undefined) { + // Copy skill directory to current directory + await copySkillOutputToCurrentDirectory(tempDirPath, process.cwd()); + } else { + await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath); + } } logger.trace(`Repository obtained via ${downloadMethod} method`); @@ -183,6 +190,40 @@ export const cleanupTempDirectory = async (directory: string): Promise => await fs.rm(directory, { recursive: true, force: true }); }; +export const copySkillOutputToCurrentDirectory = async (sourceDir: string, targetDir: string): Promise => { + const sourceClaudeDir = path.join(sourceDir, '.claude'); + const targetClaudeDir = path.join(targetDir, '.claude'); + + try { + // Check if source .claude directory exists + await fs.access(sourceClaudeDir); + } catch { + // No skill output was generated + logger.trace('No .claude directory found in source, skipping skill output copy'); + return; + } + + try { + logger.trace(`Copying skill output from: ${sourceClaudeDir} to: ${targetClaudeDir}`); + + // Copy the entire .claude directory + await fs.cp(sourceClaudeDir, targetClaudeDir, { recursive: true }); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + + if (nodeError.code === 'EPERM' || nodeError.code === 'EACCES') { + throw new RepomixError( + `Failed to copy skill output to ${targetClaudeDir}: Permission denied. + +The current directory may be protected or require elevated permissions. +Please try running from a different directory (e.g., your home directory or Documents folder).`, + ); + } + + throw new RepomixError(`Failed to copy skill output: ${(error as Error).message}`); + } +}; + export const copyOutputToCurrentDirectory = async ( sourceDir: string, targetDir: string, diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index d795813a2..e01550d96 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import pc from 'picocolors'; import type { RepomixConfigMerged } from '../config/configSchema.js'; import type { SkippedFileInfo } from '../core/file/fileCollect.js'; +import { generateDefaultSkillName } from '../core/output/skill/skillUtils.js'; import type { PackResult } from '../core/packager.js'; import type { SuspiciousFileResult } from '../core/security/securityCheck.js'; import { logger } from '../shared/logger.js'; @@ -67,8 +68,12 @@ export const reportSummary = (packResult: PackResult, config: RepomixConfigMerge logger.log(`${pc.white(' Total Chars:')} ${pc.white(packResult.totalCharacters.toLocaleString())} chars`); // Show skill output path or regular output path - if (config.generateSkill) { - const skillPath = `.claude/skills/${config.generateSkill}/`; + if (config.skillGenerate !== undefined) { + const skillName = + typeof config.skillGenerate === 'string' + ? config.skillGenerate + : generateDefaultSkillName([config.cwd], config.remoteUrl); + const skillPath = `.claude/skills/${skillName}/`; logger.log(`${pc.white(' Output:')} ${pc.white(skillPath)} ${pc.dim('(skill directory)')}`); } else { logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); diff --git a/src/cli/cliRun.ts b/src/cli/cliRun.ts index 00a04014d..a080600ff 100644 --- a/src/cli/cliRun.ts +++ b/src/cli/cliRun.ts @@ -171,8 +171,8 @@ export const run = async () => { // Skill Generation .optionsGroup('Skill Generation (Experimental)') .option( - '--generate-skill ', - 'Generate Claude Agent Skills format output to .claude/skills// directory', + '--skill-generate [name]', + 'Generate Claude Agent Skills format output to .claude/skills// directory (name auto-generated if omitted)', ) .action(commanderActionEndpoint); diff --git a/src/cli/types.ts b/src/cli/types.ts index ba25be776..bac708d24 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -39,6 +39,7 @@ export interface CliOptions extends OptionValues { // Remote Repository Options remote?: string; remoteBranch?: string; + remoteUrl?: string; // The actual remote URL (for skill name auto-generation) // Configuration Options config?: string; @@ -56,7 +57,7 @@ export interface CliOptions extends OptionValues { mcp?: boolean; // Skill Generation - generateSkill?: string; + skillGenerate?: string | boolean; // Other Options topFilesLen?: number; diff --git a/src/config/configLoad.ts b/src/config/configLoad.ts index 3770a7315..e561f5610 100644 --- a/src/config/configLoad.ts +++ b/src/config/configLoad.ts @@ -231,7 +231,7 @@ export const mergeConfigs = ( ...cliConfig.tokenCount, }, // Skill generation (CLI only) - ...(cliConfig.generateSkill && { generateSkill: cliConfig.generateSkill }), + ...(cliConfig.skillGenerate !== undefined && { skillGenerate: cliConfig.skillGenerate }), }; try { diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 775d43170..c24188541 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -138,7 +138,7 @@ export const repomixConfigCliSchema = repomixConfigBaseSchema.and( stdout: z.boolean().optional(), }) .optional(), - generateSkill: z.string().optional(), + skillGenerate: z.union([z.string(), z.boolean()]).optional(), }), ); @@ -149,6 +149,8 @@ export const repomixConfigMergedSchema = repomixConfigDefaultSchema .and( z.object({ cwd: z.string(), + // Remote URL for skill name auto-generation (set by remoteAction) + remoteUrl: z.string().optional(), }), ); diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 627cf1012..49aadd9ac 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -21,9 +21,16 @@ import { } from './outputStyleDecorate.js'; import { getMarkdownTemplate } from './outputStyles/markdownStyle.js'; import { getPlainTemplate } from './outputStyles/plainStyle.js'; -import { generateSkillMd } from './outputStyles/skillStyle.js'; import { getXmlTemplate } from './outputStyles/xmlStyle.js'; -import { generateProjectName, generateSkillDescription, validateSkillName } from './skillUtils.js'; +import { + generateFilesSection, + generateGitDiffsSection, + generateGitLogsSection, + generateStructureSection, + generateSummarySection, +} from './skill/skillSectionGenerators.js'; +import { generateSkillMd } from './skill/skillStyle.js'; +import { generateProjectName, generateSkillDescription, validateSkillName } from './skill/skillUtils.js'; const calculateMarkdownDelimiter = (files: ReadonlyArray): string => { const maxBackticks = files @@ -352,17 +359,28 @@ export const buildOutputGeneratorContext = async ( }; }; +/** + * References for skill output - each becomes a separate file + */ +export interface SkillReferences { + summary: string; + structure: string; + files: string; + gitDiffs?: string; + gitLogs?: string; +} + /** * Result of skill output generation */ export interface SkillOutputResult { skillMd: string; - codebaseMd: string; + references: SkillReferences; } /** * Generates Claude Agent Skills format output. - * Creates SKILL.md and codebase.md (Markdown fixed). + * Creates SKILL.md and separate reference files (summary.md, structure.md, files.md, etc.). */ export const generateSkillOutput = async ( skillName: string, @@ -374,7 +392,8 @@ export const generateSkillOutput = async ( gitDiffResult: GitDiffResult | undefined = undefined, gitLogResult: GitLogResult | undefined = undefined, deps = { - generateOutput, + buildOutputGeneratorContext, + sortOutputFiles, }, ): Promise => { // Validate and normalize skill name @@ -386,16 +405,10 @@ export const generateSkillOutput = async ( // Generate skill description const skillDescription = generateSkillDescription(normalizedSkillName, projectName); - // Generate SKILL.md content - const skillMd = generateSkillMd({ - skillName: normalizedSkillName, - skillDescription, - projectName, - totalFiles: processedFiles.length, - totalTokens, - }); + // Sort processed files by git change count if enabled + const sortedProcessedFiles = await deps.sortOutputFiles(processedFiles, config); - // Generate codebase.md using markdown style (fixed) + // Build output generator context with markdown style const markdownConfig: RepomixConfigMerged = { ...config, output: { @@ -404,17 +417,38 @@ export const generateSkillOutput = async ( }, }; - const codebaseMd = await deps.generateOutput( + const outputGeneratorContext = await deps.buildOutputGeneratorContext( rootDirs, markdownConfig, - processedFiles, allFilePaths, + sortedProcessedFiles, gitDiffResult, gitLogResult, ); + const renderContext = createRenderContext(outputGeneratorContext); + + // Generate each section separately + const references: SkillReferences = { + summary: generateSummarySection(renderContext), + structure: generateStructureSection(renderContext), + files: generateFilesSection(renderContext), + gitDiffs: generateGitDiffsSection(renderContext), + gitLogs: generateGitLogsSection(renderContext), + }; + + // Generate SKILL.md content with info about which reference files exist + const skillMd = generateSkillMd({ + skillName: normalizedSkillName, + skillDescription, + projectName, + totalFiles: processedFiles.length, + totalTokens, + hasGitDiffs: !!references.gitDiffs, + hasGitLogs: !!references.gitLogs, + }); return { skillMd, - codebaseMd, + references, }; }; diff --git a/src/core/output/outputStyleUtils.ts b/src/core/output/outputStyleUtils.ts new file mode 100644 index 000000000..5b844d19f --- /dev/null +++ b/src/core/output/outputStyleUtils.ts @@ -0,0 +1,223 @@ +/** + * Shared utilities for output style generation. + */ + +/** + * Map of file extensions to syntax highlighting language names. + * Based on GitHub Linguist: https://github.com/github-linguist/linguist + */ +const extensionToLanguageMap: Record = { + // JavaScript/TypeScript + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'javascript', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + tsx: 'typescript', + + // Web frameworks + vue: 'vue', + svelte: 'svelte', + astro: 'astro', + + // Python + py: 'python', + pyw: 'python', + pyi: 'python', + + // Ruby + rb: 'ruby', + erb: 'erb', + + // Java/JVM + java: 'java', + kt: 'kotlin', + kts: 'kotlin', + scala: 'scala', + groovy: 'groovy', + clj: 'clojure', + cljs: 'clojure', + cljc: 'clojure', + + // C/C++/Objective-C + c: 'c', + h: 'c', + cpp: 'cpp', + cc: 'cpp', + cxx: 'cpp', + hpp: 'cpp', + hxx: 'cpp', + m: 'objectivec', + mm: 'objectivec', + + // C#/F#/.NET + cs: 'csharp', + fs: 'fsharp', + fsx: 'fsharp', + fsi: 'fsharp', + vb: 'vb', + cshtml: 'razor', + razor: 'razor', + + // Go + go: 'go', + + // Rust + rs: 'rust', + + // Swift + swift: 'swift', + + // PHP + php: 'php', + + // Dart/Flutter + dart: 'dart', + + // Ruby templates + haml: 'haml', + slim: 'slim', + + // Functional languages + hs: 'haskell', + lhs: 'haskell', + ex: 'elixir', + exs: 'elixir', + erl: 'erlang', + hrl: 'erlang', + ml: 'ocaml', + mli: 'ocaml', + elm: 'elm', + + // Other languages + r: 'r', + R: 'r', + jl: 'julia', + nim: 'nim', + zig: 'zig', + v: 'v', + lua: 'lua', + pl: 'perl', + pm: 'perl', + raku: 'raku', + + // Shell/Scripts + sh: 'bash', + bash: 'bash', + zsh: 'zsh', + fish: 'fish', + ps1: 'powershell', + psm1: 'powershell', + bat: 'batch', + cmd: 'batch', + awk: 'awk', + + // Markup/Style + html: 'html', + htm: 'html', + xhtml: 'html', + css: 'css', + scss: 'scss', + sass: 'sass', + less: 'less', + styl: 'stylus', + + // Data formats + json: 'json', + json5: 'json5', + jsonc: 'json', + xml: 'xml', + xsl: 'xml', + xslt: 'xml', + svg: 'xml', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + ini: 'ini', + cfg: 'ini', + conf: 'ini', + + // Documentation + md: 'markdown', + mdx: 'markdown', + rst: 'rst', + tex: 'latex', + latex: 'latex', + + // Database + sql: 'sql', + prisma: 'prisma', + + // DevOps/Config + dockerfile: 'dockerfile', + tf: 'hcl', + tfvars: 'hcl', + hcl: 'hcl', + nix: 'nix', + nginx: 'nginx', + apacheconf: 'apacheconf', + + // Build systems + cmake: 'cmake', + makefile: 'makefile', + mk: 'makefile', + + // Graphics/Shaders + glsl: 'glsl', + vert: 'glsl', + frag: 'glsl', + wgsl: 'wgsl', + hlsl: 'hlsl', + + // Hardware description + vhdl: 'vhdl', + vhd: 'vhdl', + + // Smart contracts + sol: 'solidity', + + // Assembly + asm: 'asm', + s: 'asm', + + // Template engines + hbs: 'handlebars', + handlebars: 'handlebars', + mustache: 'handlebars', + ejs: 'ejs', + jinja: 'jinja', + jinja2: 'jinja', + j2: 'jinja', + liquid: 'liquid', + njk: 'nunjucks', + pug: 'pug', + jade: 'pug', + twig: 'twig', + + // API/Schema + graphql: 'graphql', + gql: 'graphql', + proto: 'protobuf', + + // Other + coffee: 'coffeescript', + vim: 'vim', + diff: 'diff', + patch: 'diff', + wasm: 'wasm', + wat: 'wasm', +}; + +/** + * Get syntax highlighting language name from file path. + * Used for Markdown code block language hints. + * + * @param filePath - The file path to extract extension from + * @returns The language name for syntax highlighting, or empty string if unknown + */ +export const getLanguageFromFilePath = (filePath: string): string => { + const extension = filePath.split('.').pop()?.toLowerCase(); + return extension ? extensionToLanguageMap[extension] || '' : ''; +}; diff --git a/src/core/output/outputStyles/markdownStyle.ts b/src/core/output/outputStyles/markdownStyle.ts index ef436aafc..c16dec653 100644 --- a/src/core/output/outputStyles/markdownStyle.ts +++ b/src/core/output/outputStyles/markdownStyle.ts @@ -1,4 +1,5 @@ import Handlebars from 'handlebars'; +import { getLanguageFromFilePath } from '../outputStyleUtils.js'; export const getMarkdownTemplate = () => { return /* md */ ` @@ -83,108 +84,6 @@ export const getMarkdownTemplate = () => { `; }; -Handlebars.registerHelper('getFileExtension', (filePath) => { - const extension = filePath.split('.').pop()?.toLowerCase(); - switch (extension) { - case 'js': - case 'jsx': - return 'javascript'; - case 'ts': - case 'tsx': - return 'typescript'; - case 'vue': - return 'vue'; - case 'py': - return 'python'; - case 'rb': - return 'ruby'; - case 'java': - return 'java'; - case 'c': - case 'cpp': - return 'cpp'; - case 'cs': - return 'csharp'; - case 'go': - return 'go'; - case 'rs': - return 'rust'; - case 'php': - return 'php'; - case 'swift': - return 'swift'; - case 'kt': - return 'kotlin'; - case 'scala': - return 'scala'; - case 'html': - return 'html'; - case 'css': - return 'css'; - case 'scss': - case 'sass': - return 'scss'; - case 'json': - return 'json'; - case 'json5': - return 'json5'; - case 'xml': - return 'xml'; - case 'yaml': - case 'yml': - return 'yaml'; - case 'md': - return 'markdown'; - case 'sh': - case 'bash': - return 'bash'; - case 'sql': - return 'sql'; - case 'dockerfile': - return 'dockerfile'; - case 'dart': - return 'dart'; - case 'fs': - case 'fsx': - return 'fsharp'; - case 'r': - return 'r'; - case 'pl': - case 'pm': - return 'perl'; - case 'lua': - return 'lua'; - case 'groovy': - return 'groovy'; - case 'hs': - return 'haskell'; - case 'ex': - case 'exs': - return 'elixir'; - case 'erl': - return 'erlang'; - case 'clj': - case 'cljs': - return 'clojure'; - case 'ps1': - return 'powershell'; - case 'vb': - return 'vb'; - case 'coffee': - return 'coffeescript'; - case 'tf': - case 'tfvars': - return 'hcl'; - case 'proto': - return 'protobuf'; - case 'pug': - return 'pug'; - case 'graphql': - case 'gql': - return 'graphql'; - case 'toml': - return 'toml'; - default: - return ''; - } +Handlebars.registerHelper('getFileExtension', (filePath: string) => { + return getLanguageFromFilePath(filePath); }); diff --git a/src/core/output/outputStyles/skillStyle.ts b/src/core/output/outputStyles/skillStyle.ts deleted file mode 100644 index b10ee8c13..000000000 --- a/src/core/output/outputStyles/skillStyle.ts +++ /dev/null @@ -1,57 +0,0 @@ -import Handlebars from 'handlebars'; - -export interface SkillRenderContext { - skillName: string; - skillDescription: string; - projectName: string; - totalFiles: number; - totalTokens: number; -} - -/** - * Returns the Handlebars template for SKILL.md. - * Following Claude Agent Skills best practices for progressive disclosure. - */ -export const getSkillTemplate = (): string => { - return /* md */ `--- -name: {{{skillName}}} -description: {{{skillDescription}}} ---- - -# {{{projectName}}} Codebase Reference - -This skill provides reference to the {{{projectName}}} codebase. - -## Statistics - -- Total Files: {{{totalFiles}}} -- Total Tokens: {{{totalTokens}}} - -## How to Use - -The complete codebase is available in \`references/codebase.md\`. - -### Reading the Codebase - -To understand the project structure, start by reading the "Directory Structure" section in the codebase file. - -For specific code details: -1. Look at the "Files" section to find the file you need -2. Each file includes its path and content -3. Use grep patterns to search for specific functions or classes - -Use this when you need to: -- Understand the project structure -- Find implementation details -- Reference code patterns -`; -}; - -/** - * Generates the SKILL.md content from the given context. - */ -export const generateSkillMd = (context: SkillRenderContext): string => { - const template = getSkillTemplate(); - const compiledTemplate = Handlebars.compile(template); - return `${compiledTemplate(context).trim()}\n`; -}; diff --git a/src/core/output/skill/skillSectionGenerators.ts b/src/core/output/skill/skillSectionGenerators.ts new file mode 100644 index 000000000..4ae6f9e55 --- /dev/null +++ b/src/core/output/skill/skillSectionGenerators.ts @@ -0,0 +1,127 @@ +import Handlebars from 'handlebars'; +import type { RenderContext } from '../outputGeneratorTypes.js'; +import { getLanguageFromFilePath } from '../outputStyleUtils.js'; + +/** + * Generates the summary section for skill output. + * Contains purpose, file format, usage guidelines, and notes. + */ +export const generateSummarySection = (context: RenderContext): string => { + const template = Handlebars.compile(`{{{generationHeader}}} + +# File Summary + +## Purpose +{{{summaryPurpose}}} + +## File Format +{{{summaryFileFormat}}} + +## Usage Guidelines +{{{summaryUsageGuidelines}}} + +## Notes +{{{summaryNotes}}} +`); + + return template(context).trim(); +}; + +/** + * Generates the directory structure section for skill output. + */ +export const generateStructureSection = (context: RenderContext): string => { + if (!context.directoryStructureEnabled) { + return ''; + } + + const template = Handlebars.compile(`# Directory Structure + +\`\`\` +{{{treeString}}} +\`\`\` +`); + + return template(context).trim(); +}; + +/** + * Generates the files section for skill output. + * Contains all file contents with syntax highlighting. + */ +export const generateFilesSection = (context: RenderContext): string => { + if (!context.filesEnabled) { + return ''; + } + + // Register the helper if not already registered + if (!Handlebars.helpers.getFileExtension) { + Handlebars.registerHelper('getFileExtension', (filePath: string) => { + return getLanguageFromFilePath(filePath); + }); + } + + const template = Handlebars.compile(`# Files + +{{#each processedFiles}} +## File: {{{this.path}}} +{{{../markdownCodeBlockDelimiter}}}{{{getFileExtension this.path}}} +{{{this.content}}} +{{{../markdownCodeBlockDelimiter}}} + +{{/each}} +`); + + return template(context).trim(); +}; + +/** + * Generates the git diffs section for skill output. + * Returns undefined if git diffs are not enabled. + */ +export const generateGitDiffsSection = (context: RenderContext): string | undefined => { + if (!context.gitDiffEnabled) { + return undefined; + } + + const template = Handlebars.compile(`# Git Diffs + +## Working Tree Changes +\`\`\`diff +{{{gitDiffWorkTree}}} +\`\`\` + +## Staged Changes +\`\`\`diff +{{{gitDiffStaged}}} +\`\`\` +`); + + return template(context).trim(); +}; + +/** + * Generates the git logs section for skill output. + * Returns undefined if git logs are not enabled. + */ +export const generateGitLogsSection = (context: RenderContext): string | undefined => { + if (!context.gitLogEnabled || !context.gitLogCommits) { + return undefined; + } + + const template = Handlebars.compile(`# Git Logs + +{{#each gitLogCommits}} +## Commit: {{{this.date}}} +**Message:** {{{this.message}}} + +**Files:** +{{#each this.files}} +- {{{this}}} +{{/each}} + +{{/each}} +`); + + return template(context).trim(); +}; diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts new file mode 100644 index 000000000..d9fa6b38a --- /dev/null +++ b/src/core/output/skill/skillStyle.ts @@ -0,0 +1,78 @@ +import Handlebars from 'handlebars'; + +export interface SkillRenderContext { + skillName: string; + skillDescription: string; + projectName: string; + totalFiles: number; + totalTokens: number; + hasGitDiffs?: boolean; + hasGitLogs?: boolean; +} + +/** + * Returns the Handlebars template for SKILL.md. + * Following Claude Agent Skills best practices for progressive disclosure. + */ +export const getSkillTemplate = (): string => { + return /* md */ `--- +name: {{{skillName}}} +description: {{{skillDescription}}} +--- + +# {{{projectName}}} Codebase Reference + +This skill provides reference to the {{{projectName}}} codebase. + +## Statistics + +- Total Files: {{{totalFiles}}} +- Total Tokens: {{{totalTokens}}} + +## Reference Files + +### Summary (\`references/summary.md\`) +Overview of the packed content, including purpose, file format description, and important notes about excluded files. + +### Directory Structure (\`references/structure.md\`) +Tree view of the project's directory structure. Start here to understand the overall layout. + +### Files (\`references/files.md\`) +Complete file contents. Each file includes its path and full source code. + +{{#if hasGitDiffs}} +### Git Diffs (\`references/git-diffs.md\`) +Current uncommitted changes (working tree and staged). + +{{/if}} +{{#if hasGitLogs}} +### Git Logs (\`references/git-logs.md\`) +Recent commit history with dates, messages, and changed files. + +{{/if}} +## How to Use + +1. **Understand the layout**: Read \`structure.md\` first to see the project organization +2. **Find specific code**: Search in \`files.md\` for functions, classes, or specific implementations +3. **Get context**: Check \`summary.md\` for information about what's included and excluded +{{#if hasGitDiffs}} +4. **Check recent changes**: Review \`git-diffs.md\` for uncommitted modifications +{{/if}} +{{#if hasGitLogs}} +{{#if hasGitDiffs}} +5. **Review history**: See \`git-logs.md\` for recent commit history +{{else}} +4. **Review history**: See \`git-logs.md\` for recent commit history +{{/if}} +{{/if}} +`; +}; + +/** + * Generates the SKILL.md content from the given context. + */ +export const generateSkillMd = (context: SkillRenderContext): string => { + const template = getSkillTemplate(); + const compiledTemplate = Handlebars.compile(template); + return `${compiledTemplate(context).trim()}\n`; +}; diff --git a/src/core/output/skillUtils.ts b/src/core/output/skill/skillUtils.ts similarity index 58% rename from src/core/output/skillUtils.ts rename to src/core/output/skill/skillUtils.ts index 3f022a985..d82016bcb 100644 --- a/src/core/output/skillUtils.ts +++ b/src/core/output/skill/skillUtils.ts @@ -2,6 +2,7 @@ import path from 'node:path'; const SKILL_NAME_MAX_LENGTH = 64; const SKILL_DESCRIPTION_MAX_LENGTH = 1024; +const SKILL_NAME_PREFIX = 'repomix-reference'; /** * Converts a string to kebab-case. @@ -55,3 +56,50 @@ export const generateSkillDescription = (_skillName: string, projectName: string return description.substring(0, SKILL_DESCRIPTION_MAX_LENGTH); }; + +/** + * Extracts repository name from a URL or shorthand format. + * Examples: + * - https://github.com/yamadashy/repomix → repomix + * - yamadashy/repomix → repomix + * - git@github.com:yamadashy/repomix.git → repomix + */ +export const extractRepoName = (url: string): string => { + // Remove .git suffix if present + const cleanUrl = url.replace(/\.git$/, ''); + + // Try to match the last path segment + const match = cleanUrl.match(/\/([^/]+)$/); + if (match) { + return match[1]; + } + + // For shorthand format like "user/repo" + const shorthandMatch = cleanUrl.match(/^[^/]+\/([^/]+)$/); + if (shorthandMatch) { + return shorthandMatch[1]; + } + + return 'unknown'; +}; + +/** + * Generates a default skill name based on the context. + * - For remote repositories: repomix-reference- + * - For local directories: repomix-reference- + */ +export const generateDefaultSkillName = (rootDirs: string[], remoteUrl?: string): string => { + let baseName: string; + + if (remoteUrl) { + // Extract repo name from remote URL + baseName = extractRepoName(remoteUrl); + } else { + // Use local directory name + const primaryDir = rootDirs[0] || '.'; + baseName = path.basename(path.resolve(primaryDir)); + } + + const skillName = `${SKILL_NAME_PREFIX}-${toKebabCase(baseName)}`; + return validateSkillName(skillName); +}; diff --git a/src/core/packager.ts b/src/core/packager.ts index 35f9b0ab8..bf07578fa 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -10,6 +10,7 @@ import { getGitDiffs } from './git/gitDiffHandle.js'; import { getGitLogs } from './git/gitLogHandle.js'; import { calculateMetrics } from './metrics/calculateMetrics.js'; import { generateOutput, generateSkillOutput } from './output/outputGenerate.js'; +import { generateDefaultSkillName } from './output/skill/skillUtils.js'; import { copyToClipboardIfEnabled } from './packager/copyToClipboardIfEnabled.js'; import { writeOutputToDisk } from './packager/writeOutputToDisk.js'; import { writeSkillOutput } from './packager/writeSkillOutput.js'; @@ -38,6 +39,7 @@ const defaultDeps = { processFiles, generateOutput, generateSkillOutput, + generateDefaultSkillName, validateFileSafety, writeOutputToDisk, writeSkillOutput, @@ -124,45 +126,58 @@ export const pack = async ( let output: string; // Check if skill generation is requested - if (config.generateSkill) { - // Generate codebaseMd first using markdown style - const markdownConfig: RepomixConfigMerged = { - ...config, - output: { - ...config.output, - style: 'markdown', - }, - }; - const codebaseMd = await withMemoryLogging('Generate Codebase Markdown', () => - deps.generateOutput(rootDirs, markdownConfig, processedFiles, allFilePaths, gitDiffResult, gitLogResult), + if (config.skillGenerate !== undefined) { + // Resolve skill name: use provided name or auto-generate + const skillName = + typeof config.skillGenerate === 'string' + ? config.skillGenerate + : deps.generateDefaultSkillName(rootDirs, config.remoteUrl); + + // Generate skill output (includes metrics calculation internally) + const skillOutput = await withMemoryLogging('Generate Skill Output', () => + deps.generateSkillOutput( + skillName, + rootDirs, + config, + processedFiles, + allFilePaths, + 0, + gitDiffResult, + gitLogResult, + ), ); - // Calculate metrics from codebaseMd to get accurate token count - const codebaseMetrics = await withMemoryLogging('Calculate Codebase Metrics', () => - deps.calculateMetrics(processedFiles, codebaseMd, progressCallback, config, gitDiffResult, gitLogResult), + // Calculate metrics from files section to get accurate token count + const skillMetrics = await withMemoryLogging('Calculate Skill Metrics', () => + deps.calculateMetrics( + processedFiles, + skillOutput.references.files, + progressCallback, + config, + gitDiffResult, + gitLogResult, + ), ); - // Generate skill output with accurate token count - const skillOutput = await withMemoryLogging('Generate Skill Output', () => + // Regenerate skill output with accurate token count + const finalSkillOutput = await withMemoryLogging('Generate Final Skill Output', () => deps.generateSkillOutput( - config.generateSkill as string, + skillName, rootDirs, config, processedFiles, allFilePaths, - codebaseMetrics.totalTokens, + skillMetrics.totalTokens, gitDiffResult, gitLogResult, ), ); progressCallback('Writing skill output...'); - await withMemoryLogging('Write Skill Output', () => - deps.writeSkillOutput(skillOutput, config.generateSkill as string, config.cwd), - ); + await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(finalSkillOutput, skillName, config.cwd)); - // Use codebaseMd for final metrics - output = codebaseMd; + // Use files section for final metrics (most representative of content size) + output = finalSkillOutput.references.files; } else { output = await withMemoryLogging('Generate Output', () => deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index 39c8d67fb..b38f86527 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -10,7 +10,11 @@ const SKILL_DIR_NAME = '.claude/skills'; * .claude/skills// * ├── SKILL.md * └── references/ - * └── codebase.md + * ├── summary.md + * ├── structure.md + * ├── files.md + * ├── git-diffs.md (if enabled) + * └── git-logs.md (if enabled) */ export const writeSkillOutput = async ( output: SkillOutputResult, @@ -25,16 +29,24 @@ export const writeSkillOutput = async ( const referencesDir = path.join(skillDir, 'references'); // Create directories - await deps.mkdir(skillDir, { recursive: true }); await deps.mkdir(referencesDir, { recursive: true }); // Write SKILL.md const skillMdPath = path.join(skillDir, 'SKILL.md'); await deps.writeFile(skillMdPath, output.skillMd, 'utf-8'); - // Write references/codebase.md - const codebaseMdPath = path.join(referencesDir, 'codebase.md'); - await deps.writeFile(codebaseMdPath, output.codebaseMd, 'utf-8'); + // Write reference files + await deps.writeFile(path.join(referencesDir, 'summary.md'), output.references.summary, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); + + // Write optional git files + if (output.references.gitDiffs) { + await deps.writeFile(path.join(referencesDir, 'git-diffs.md'), output.references.gitDiffs, 'utf-8'); + } + if (output.references.gitLogs) { + await deps.writeFile(path.join(referencesDir, 'git-logs.md'), output.references.gitLogs, 'utf-8'); + } return skillDir; }; diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index 5fd51c4d4..70771065b 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -10,8 +10,9 @@ const generateSkillInputSchema = z.object({ directory: z.string().describe('Directory to pack (Absolute path)'), skillName: z .string() + .optional() .describe( - 'Name of the skill to generate (kebab-case, max 64 chars). Will be normalized if not in kebab-case. Used for the skill directory name and SKILL.md metadata.', + 'Name of the skill to generate (kebab-case, max 64 chars). Will be normalized if not in kebab-case. Used for the skill directory name and SKILL.md metadata. If omitted, auto-generates as "repomix-reference-".', ), compress: z .boolean() @@ -47,7 +48,7 @@ export const registerGenerateSkillTool = (mcpServer: McpServer) => { { title: 'Generate Claude Agent Skill', description: - 'Generate a Claude Agent Skill from a local code directory. Creates a skill package at .claude/skills// containing SKILL.md (entry point with metadata) and references/codebase.md (the packed codebase in Markdown format). This skill can be used by Claude to understand and reference the codebase.', + 'Generate a Claude Agent Skill from a local code directory. Creates a skill package at .claude/skills// containing SKILL.md (entry point with metadata) and references/ folder with summary.md, structure.md, files.md, and optionally git-diffs.md and git-logs.md. This skill can be used by Claude to understand and reference the codebase.', inputSchema: generateSkillInputSchema, outputSchema: generateSkillOutputSchema, annotations: { @@ -59,8 +60,9 @@ export const registerGenerateSkillTool = (mcpServer: McpServer) => { }, async ({ directory, skillName, compress, includePatterns, ignorePatterns }): Promise => { try { + // skillGenerate can be a string (explicit name) or true (auto-generate) const cliOptions = { - generateSkill: skillName, + skillGenerate: skillName ?? true, compress, include: includePatterns, ignore: ignorePatterns, @@ -76,11 +78,14 @@ export const registerGenerateSkillTool = (mcpServer: McpServer) => { } const { packResult, config } = result; - const skillPath = path.join(directory, '.claude', 'skills', config.generateSkill || skillName); + // Get the actual skill name from config (may be auto-generated) + const actualSkillName = + typeof config.skillGenerate === 'string' ? config.skillGenerate : skillName || 'repomix-reference'; + const skillPath = path.join(directory, '.claude', 'skills', actualSkillName); return buildMcpToolSuccessResponse({ skillPath, - skillName: config.generateSkill || skillName, + skillName: actualSkillName, totalFiles: packResult.totalFiles, totalTokens: packResult.totalTokens, description: `Successfully generated Claude Agent Skill at ${skillPath}. The skill contains ${packResult.totalFiles} files with ${packResult.totalTokens.toLocaleString()} tokens.`, diff --git a/tests/core/output/outputStyles/markdownStyle.test.ts b/tests/core/output/outputStyles/markdownStyle.test.ts index 1366137f9..8344a8da6 100644 --- a/tests/core/output/outputStyles/markdownStyle.test.ts +++ b/tests/core/output/outputStyles/markdownStyle.test.ts @@ -143,7 +143,7 @@ describe('markdownStyle', () => { expect(getExtension('file.html')).toBe('html'); expect(getExtension('file.css')).toBe('css'); expect(getExtension('file.scss')).toBe('scss'); - expect(getExtension('file.sass')).toBe('scss'); + expect(getExtension('file.sass')).toBe('sass'); expect(getExtension('file.vue')).toBe('vue'); }); @@ -158,7 +158,7 @@ describe('markdownStyle', () => { // System programming languages test('should handle system programming language extensions', () => { - expect(getExtension('file.c')).toBe('cpp'); + expect(getExtension('file.c')).toBe('c'); expect(getExtension('file.cpp')).toBe('cpp'); expect(getExtension('file.rs')).toBe('rust'); expect(getExtension('file.swift')).toBe('swift'); diff --git a/tests/core/output/outputStyles/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts similarity index 54% rename from tests/core/output/outputStyles/skillStyle.test.ts rename to tests/core/output/skill/skillStyle.test.ts index 701dde06c..6a339c45e 100644 --- a/tests/core/output/outputStyles/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { generateSkillMd, getSkillTemplate } from '../../../../src/core/output/outputStyles/skillStyle.js'; +import { generateSkillMd, getSkillTemplate } from '../../../../src/core/output/skill/skillStyle.js'; describe('skillStyle', () => { describe('getSkillTemplate', () => { @@ -9,7 +9,7 @@ describe('skillStyle', () => { expect(template).toContain('name:'); expect(template).toContain('description:'); expect(template).toContain('# '); - expect(template).toContain('references/codebase.md'); + expect(template).toContain('references/'); }); test('should include statistics section', () => { @@ -22,7 +22,14 @@ describe('skillStyle', () => { test('should include how to use section', () => { const template = getSkillTemplate(); expect(template).toContain('## How to Use'); - expect(template).toContain('Reading the Codebase'); + expect(template).toContain('Understand the layout'); + }); + + test('should reference multiple files', () => { + const template = getSkillTemplate(); + expect(template).toContain('references/summary.md'); + expect(template).toContain('references/structure.md'); + expect(template).toContain('references/files.md'); }); }); @@ -62,7 +69,50 @@ describe('skillStyle', () => { expect(result.endsWith('\n')).toBe(true); }); - test('should include reference to codebase.md', () => { + test('should include references to multiple files', () => { + const context = { + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalTokens: 100, + }; + + const result = generateSkillMd(context); + expect(result).toContain('`references/summary.md`'); + expect(result).toContain('`references/structure.md`'); + expect(result).toContain('`references/files.md`'); + }); + + test('should include git sections when hasGitDiffs is true', () => { + const context = { + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalTokens: 100, + hasGitDiffs: true, + }; + + const result = generateSkillMd(context); + expect(result).toContain('references/git-diffs.md'); + }); + + test('should include git log section when hasGitLogs is true', () => { + const context = { + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalTokens: 100, + hasGitLogs: true, + }; + + const result = generateSkillMd(context); + expect(result).toContain('references/git-logs.md'); + }); + + test('should not include git sections when not enabled', () => { const context = { skillName: 'test-skill', skillDescription: 'Test description', @@ -72,7 +122,8 @@ describe('skillStyle', () => { }; const result = generateSkillMd(context); - expect(result).toContain('`references/codebase.md`'); + expect(result).not.toContain('git-diffs.md'); + expect(result).not.toContain('git-logs.md'); }); }); }); diff --git a/tests/core/output/skillUtils.test.ts b/tests/core/output/skill/skillUtils.test.ts similarity index 98% rename from tests/core/output/skillUtils.test.ts rename to tests/core/output/skill/skillUtils.test.ts index b5336d646..121c4a303 100644 --- a/tests/core/output/skillUtils.test.ts +++ b/tests/core/output/skill/skillUtils.test.ts @@ -4,7 +4,7 @@ import { generateSkillDescription, toKebabCase, validateSkillName, -} from '../../../src/core/output/skillUtils.js'; +} from '../../../../src/core/output/skill/skillUtils.js'; describe('skillUtils', () => { describe('toKebabCase', () => { diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/packager/writeSkillOutput.test.ts index ed1dc3d8c..be84c2e5f 100644 --- a/tests/core/packager/writeSkillOutput.test.ts +++ b/tests/core/packager/writeSkillOutput.test.ts @@ -9,7 +9,11 @@ describe('writeSkillOutput', () => { const output = { skillMd: '---\nname: test-skill\n---\n# Test Skill', - codebaseMd: '# Codebase\n\nFile contents here', + references: { + summary: '# Summary\n\nPurpose and format description.', + structure: '# Directory Structure\n\n```\nsrc/\n index.ts\n```', + files: '# Files\n\n## File: src/index.ts\n```typescript\nconsole.log("hello");\n```', + }, }; const skillName = 'test-skill'; @@ -20,8 +24,7 @@ describe('writeSkillOutput', () => { writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, }); - // Check directories were created - expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', skillName), { recursive: true }); + // Check references directory was created (includes skill directory with recursive: true) expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', skillName, 'references'), { recursive: true, }); @@ -33,8 +36,18 @@ describe('writeSkillOutput', () => { 'utf-8', ); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'codebase.md'), - output.codebaseMd, + path.join(cwd, '.claude/skills', skillName, 'references', 'summary.md'), + output.references.summary, + 'utf-8', + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'references', 'structure.md'), + output.references.structure, + 'utf-8', + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'references', 'files.md'), + output.references.files, 'utf-8', ); @@ -42,13 +55,53 @@ describe('writeSkillOutput', () => { expect(result).toBe(path.join(cwd, '.claude/skills', skillName)); }); + test('should write git-diffs.md and git-logs.md when provided', async () => { + const mockMkdir = vi.fn().mockResolvedValue(undefined); + const mockWriteFile = vi.fn().mockResolvedValue(undefined); + + const output = { + skillMd: '---\nname: test-skill\n---\n# Test Skill', + references: { + summary: '# Summary', + structure: '# Structure', + files: '# Files', + gitDiffs: '# Git Diffs\n\n```diff\n+added line\n```', + gitLogs: '# Git Logs\n\n## Commit: abc123\nFix bug', + }, + }; + + const skillName = 'test-skill'; + const cwd = '/test/project'; + + await writeSkillOutput(output, skillName, cwd, { + mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, + writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, + }); + + // Check git files were written + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'references', 'git-diffs.md'), + output.references.gitDiffs, + 'utf-8', + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(cwd, '.claude/skills', skillName, 'references', 'git-logs.md'), + output.references.gitLogs, + 'utf-8', + ); + }); + test('should handle skill names with special characters', async () => { const mockMkdir = vi.fn().mockResolvedValue(undefined); const mockWriteFile = vi.fn().mockResolvedValue(undefined); const output = { skillMd: '# Skill', - codebaseMd: '# Codebase', + references: { + summary: '# Summary', + structure: '# Structure', + files: '# Files', + }, }; const skillName = 'my-special-skill'; @@ -59,6 +112,8 @@ describe('writeSkillOutput', () => { writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, }); - expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', 'my-special-skill'), { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', 'my-special-skill', 'references'), { + recursive: true, + }); }); }); From f854dd4250f9b9456fee84b74aa493cb971a87e4 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 17:50:29 +0900 Subject: [PATCH 03/30] refactor(skill): Improve skill output generation and add tests Refactor and improve the skill generation feature based on code review: - Split generateSkillOutput into generateSkillReferences and generateSkillMdFromReferences to avoid double generation - Add try-catch error handling to writeSkillOutput with proper permission error messages - Add unit tests for skillSectionGenerators (12 tests) - Add validation tests for --skill-generate flag combinations - Update createMockConfig to support skillGenerate and remoteUrl --- src/core/output/outputGenerate.ts | 50 +++-- src/core/packager.ts | 37 ++-- src/core/packager/writeSkillOutput.ts | 48 +++-- tests/cli/actions/defaultAction.test.ts | 86 ++++++++ .../skill/skillSectionGenerators.test.ts | 184 ++++++++++++++++++ tests/testing/testUtils.ts | 5 +- 6 files changed, 357 insertions(+), 53 deletions(-) create mode 100644 tests/core/output/skill/skillSectionGenerators.test.ts diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 49aadd9ac..15f1400b0 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -370,6 +370,17 @@ export interface SkillReferences { gitLogs?: string; } +/** + * Result of skill references generation (without SKILL.md) + */ +export interface SkillReferencesResult { + references: SkillReferences; + skillName: string; + projectName: string; + skillDescription: string; + totalFiles: number; +} + /** * Result of skill output generation */ @@ -379,23 +390,22 @@ export interface SkillOutputResult { } /** - * Generates Claude Agent Skills format output. - * Creates SKILL.md and separate reference files (summary.md, structure.md, files.md, etc.). + * Generates skill reference files (summary, structure, files, git-diffs, git-logs). + * This is the first step - call this, calculate metrics, then call generateSkillMdFromReferences. */ -export const generateSkillOutput = async ( +export const generateSkillReferences = async ( skillName: string, rootDirs: string[], config: RepomixConfigMerged, processedFiles: ProcessedFile[], allFilePaths: string[], - totalTokens: number, gitDiffResult: GitDiffResult | undefined = undefined, gitLogResult: GitLogResult | undefined = undefined, deps = { buildOutputGeneratorContext, sortOutputFiles, }, -): Promise => { +): Promise => { // Validate and normalize skill name const normalizedSkillName = validateSkillName(skillName); @@ -436,19 +446,35 @@ export const generateSkillOutput = async ( gitLogs: generateGitLogsSection(renderContext), }; - // Generate SKILL.md content with info about which reference files exist - const skillMd = generateSkillMd({ + return { + references, skillName: normalizedSkillName, - skillDescription, projectName, - totalFiles: processedFiles.length, + skillDescription, + totalFiles: sortedProcessedFiles.length, + }; +}; + +/** + * Generates SKILL.md content from references result and token count. + * This is the second step - call after calculating metrics. + */ +export const generateSkillMdFromReferences = ( + referencesResult: SkillReferencesResult, + totalTokens: number, +): SkillOutputResult => { + const skillMd = generateSkillMd({ + skillName: referencesResult.skillName, + skillDescription: referencesResult.skillDescription, + projectName: referencesResult.projectName, + totalFiles: referencesResult.totalFiles, totalTokens, - hasGitDiffs: !!references.gitDiffs, - hasGitLogs: !!references.gitLogs, + hasGitDiffs: !!referencesResult.references.gitDiffs, + hasGitLogs: !!referencesResult.references.gitLogs, }); return { skillMd, - references, + references: referencesResult.references, }; }; diff --git a/src/core/packager.ts b/src/core/packager.ts index bf07578fa..f4631c14c 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -9,7 +9,7 @@ import type { ProcessedFile } from './file/fileTypes.js'; import { getGitDiffs } from './git/gitDiffHandle.js'; import { getGitLogs } from './git/gitLogHandle.js'; import { calculateMetrics } from './metrics/calculateMetrics.js'; -import { generateOutput, generateSkillOutput } from './output/outputGenerate.js'; +import { generateOutput, generateSkillMdFromReferences, generateSkillReferences } from './output/outputGenerate.js'; import { generateDefaultSkillName } from './output/skill/skillUtils.js'; import { copyToClipboardIfEnabled } from './packager/copyToClipboardIfEnabled.js'; import { writeOutputToDisk } from './packager/writeOutputToDisk.js'; @@ -38,7 +38,8 @@ const defaultDeps = { collectFiles, processFiles, generateOutput, - generateSkillOutput, + generateSkillReferences, + generateSkillMdFromReferences, generateDefaultSkillName, validateFileSafety, writeOutputToDisk, @@ -133,25 +134,24 @@ export const pack = async ( ? config.skillGenerate : deps.generateDefaultSkillName(rootDirs, config.remoteUrl); - // Generate skill output (includes metrics calculation internally) - const skillOutput = await withMemoryLogging('Generate Skill Output', () => - deps.generateSkillOutput( + // Step 1: Generate skill references (summary, structure, files, git-diffs, git-logs) + const skillReferencesResult = await withMemoryLogging('Generate Skill References', () => + deps.generateSkillReferences( skillName, rootDirs, config, processedFiles, allFilePaths, - 0, gitDiffResult, gitLogResult, ), ); - // Calculate metrics from files section to get accurate token count + // Step 2: Calculate metrics from files section to get accurate token count const skillMetrics = await withMemoryLogging('Calculate Skill Metrics', () => deps.calculateMetrics( processedFiles, - skillOutput.references.files, + skillReferencesResult.references.files, progressCallback, config, gitDiffResult, @@ -159,25 +159,16 @@ export const pack = async ( ), ); - // Regenerate skill output with accurate token count - const finalSkillOutput = await withMemoryLogging('Generate Final Skill Output', () => - deps.generateSkillOutput( - skillName, - rootDirs, - config, - processedFiles, - allFilePaths, - skillMetrics.totalTokens, - gitDiffResult, - gitLogResult, - ), - ); + // Step 3: Generate SKILL.md with accurate token count + const skillOutput = deps.generateSkillMdFromReferences(skillReferencesResult, skillMetrics.totalTokens); progressCallback('Writing skill output...'); - await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(finalSkillOutput, skillName, config.cwd)); + await withMemoryLogging('Write Skill Output', () => + deps.writeSkillOutput(skillOutput, skillReferencesResult.skillName, config.cwd), + ); // Use files section for final metrics (most representative of content size) - output = finalSkillOutput.references.files; + output = skillOutput.references.files; } else { output = await withMemoryLogging('Generate Output', () => deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index b38f86527..9714502d4 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { RepomixError } from '../../shared/errorHandle.js'; import type { SkillOutputResult } from '../output/outputGenerate.js'; const SKILL_DIR_NAME = '.claude/skills'; @@ -28,25 +29,38 @@ export const writeSkillOutput = async ( const skillDir = path.join(cwd, SKILL_DIR_NAME, skillName); const referencesDir = path.join(skillDir, 'references'); - // Create directories - await deps.mkdir(referencesDir, { recursive: true }); + try { + // Create directories + await deps.mkdir(referencesDir, { recursive: true }); - // Write SKILL.md - const skillMdPath = path.join(skillDir, 'SKILL.md'); - await deps.writeFile(skillMdPath, output.skillMd, 'utf-8'); + // Write SKILL.md + const skillMdPath = path.join(skillDir, 'SKILL.md'); + await deps.writeFile(skillMdPath, output.skillMd, 'utf-8'); - // Write reference files - await deps.writeFile(path.join(referencesDir, 'summary.md'), output.references.summary, 'utf-8'); - await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); - await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); + // Write reference files + await deps.writeFile(path.join(referencesDir, 'summary.md'), output.references.summary, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); - // Write optional git files - if (output.references.gitDiffs) { - await deps.writeFile(path.join(referencesDir, 'git-diffs.md'), output.references.gitDiffs, 'utf-8'); - } - if (output.references.gitLogs) { - await deps.writeFile(path.join(referencesDir, 'git-logs.md'), output.references.gitLogs, 'utf-8'); - } + // Write optional git files + if (output.references.gitDiffs) { + await deps.writeFile(path.join(referencesDir, 'git-diffs.md'), output.references.gitDiffs, 'utf-8'); + } + if (output.references.gitLogs) { + await deps.writeFile(path.join(referencesDir, 'git-logs.md'), output.references.gitLogs, 'utf-8'); + } - return skillDir; + return skillDir; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'EPERM' || nodeError.code === 'EACCES') { + throw new RepomixError( + `Failed to write skill output to ${skillDir}: Permission denied. Please check directory permissions.`, + { cause: error instanceof Error ? error : undefined }, + ); + } + throw new RepomixError(`Failed to write skill output: ${error instanceof Error ? error.message : String(error)}`, { + cause: error instanceof Error ? error : undefined, + }); + } }; diff --git a/tests/cli/actions/defaultAction.test.ts b/tests/cli/actions/defaultAction.test.ts index 7985d2eb8..c2858b9fc 100644 --- a/tests/cli/actions/defaultAction.test.ts +++ b/tests/cli/actions/defaultAction.test.ts @@ -304,5 +304,91 @@ describe('defaultAction', () => { expect(config.ignore?.useDefaultPatterns).toBe(false); }); + + it('should handle --skill-generate with string name', () => { + const options: CliOptions = { + skillGenerate: 'my-skill', + }; + const config = buildCliConfig(options); + + expect(config.skillGenerate).toBe('my-skill'); + }); + + it('should handle --skill-generate without name (boolean true)', () => { + const options: CliOptions = { + skillGenerate: true, + }; + const config = buildCliConfig(options); + + expect(config.skillGenerate).toBe(true); + }); + }); + + describe('skill-generate validation', () => { + it('should throw error when --skill-generate is used with --stdout', async () => { + vi.mocked(configLoader.mergeConfigs).mockReturnValue( + createMockConfig({ + cwd: process.cwd(), + skillGenerate: 'my-skill', + output: { + stdout: true, + filePath: 'output.txt', + style: 'plain', + parsableStyle: false, + fileSummary: true, + directoryStructure: true, + topFilesLength: 5, + showLineNumbers: false, + removeComments: false, + removeEmptyLines: false, + compress: false, + copyToClipboard: false, + files: true, + }, + }), + ); + + const options: CliOptions = { + skillGenerate: 'my-skill', + stdout: true, + }; + + await expect(runDefaultAction(['.'], process.cwd(), options)).rejects.toThrow( + '--skill-generate cannot be used with --stdout', + ); + }); + + it('should throw error when --skill-generate is used with --copy', async () => { + vi.mocked(configLoader.mergeConfigs).mockReturnValue( + createMockConfig({ + cwd: process.cwd(), + skillGenerate: 'my-skill', + output: { + copyToClipboard: true, + stdout: false, + filePath: 'output.txt', + style: 'plain', + parsableStyle: false, + fileSummary: true, + directoryStructure: true, + topFilesLength: 5, + showLineNumbers: false, + removeComments: false, + removeEmptyLines: false, + compress: false, + files: true, + }, + }), + ); + + const options: CliOptions = { + skillGenerate: 'my-skill', + copy: true, + }; + + await expect(runDefaultAction(['.'], process.cwd(), options)).rejects.toThrow( + '--skill-generate cannot be used with --copy', + ); + }); }); }); diff --git a/tests/core/output/skill/skillSectionGenerators.test.ts b/tests/core/output/skill/skillSectionGenerators.test.ts new file mode 100644 index 000000000..28082e2a0 --- /dev/null +++ b/tests/core/output/skill/skillSectionGenerators.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'vitest'; +import type { RenderContext } from '../../../../src/core/output/outputGeneratorTypes.js'; +import { + generateFilesSection, + generateGitDiffsSection, + generateGitLogsSection, + generateStructureSection, + generateSummarySection, +} from '../../../../src/core/output/skill/skillSectionGenerators.js'; + +const createMockContext = (overrides: Partial = {}): RenderContext => ({ + generationHeader: 'Generated by Repomix', + summaryPurpose: 'This file contains a packed representation of the entire repository.', + summaryFileFormat: 'The content is organized as follows...', + summaryUsageGuidelines: 'Use this file as context for AI assistants.', + summaryNotes: 'Some files may have been excluded.', + headerText: '', + instruction: '', + treeString: 'src/\n index.ts\n utils.ts', + processedFiles: [ + { path: 'src/index.ts', content: 'console.log("hello");' }, + { path: 'src/utils.ts', content: 'export const sum = (a, b) => a + b;' }, + ], + fileSummaryEnabled: true, + directoryStructureEnabled: true, + filesEnabled: true, + escapeFileContent: false, + markdownCodeBlockDelimiter: '```', + gitDiffEnabled: false, + gitDiffWorkTree: undefined, + gitDiffStaged: undefined, + gitLogEnabled: false, + gitLogContent: undefined, + gitLogCommits: undefined, + ...overrides, +}); + +describe('skillSectionGenerators', () => { + describe('generateSummarySection', () => { + test('should generate summary section with all fields', () => { + const context = createMockContext(); + const result = generateSummarySection(context); + + expect(result).toContain('Generated by Repomix'); + expect(result).toContain('# File Summary'); + expect(result).toContain('## Purpose'); + expect(result).toContain('This file contains a packed representation'); + expect(result).toContain('## File Format'); + expect(result).toContain('## Usage Guidelines'); + expect(result).toContain('## Notes'); + }); + }); + + describe('generateStructureSection', () => { + test('should generate structure section with tree string', () => { + const context = createMockContext(); + const result = generateStructureSection(context); + + expect(result).toContain('# Directory Structure'); + expect(result).toContain('```'); + expect(result).toContain('src/'); + expect(result).toContain('index.ts'); + }); + + test('should return empty string when directory structure is disabled', () => { + const context = createMockContext({ directoryStructureEnabled: false }); + const result = generateStructureSection(context); + + expect(result).toBe(''); + }); + }); + + describe('generateFilesSection', () => { + test('should generate files section with all files', () => { + const context = createMockContext(); + const result = generateFilesSection(context); + + expect(result).toContain('# Files'); + expect(result).toContain('## File: src/index.ts'); + expect(result).toContain('console.log("hello");'); + expect(result).toContain('## File: src/utils.ts'); + expect(result).toContain('export const sum'); + }); + + test('should include syntax highlighting language', () => { + const context = createMockContext(); + const result = generateFilesSection(context); + + expect(result).toContain('```typescript'); + }); + + test('should return empty string when files are disabled', () => { + const context = createMockContext({ filesEnabled: false }); + const result = generateFilesSection(context); + + expect(result).toBe(''); + }); + + test('should handle files without known extensions', () => { + const context = createMockContext({ + processedFiles: [{ path: 'Dockerfile', content: 'FROM node:18' }], + }); + const result = generateFilesSection(context); + + expect(result).toContain('## File: Dockerfile'); + expect(result).toContain('FROM node:18'); + }); + }); + + describe('generateGitDiffsSection', () => { + test('should generate git diffs section when enabled', () => { + const context = createMockContext({ + gitDiffEnabled: true, + gitDiffWorkTree: '- old line\n+ new line', + gitDiffStaged: '- staged old\n+ staged new', + }); + const result = generateGitDiffsSection(context); + + expect(result).not.toBeUndefined(); + expect(result).toContain('# Git Diffs'); + expect(result).toContain('## Working Tree Changes'); + expect(result).toContain('```diff'); + expect(result).toContain('- old line'); + expect(result).toContain('+ new line'); + expect(result).toContain('## Staged Changes'); + expect(result).toContain('- staged old'); + }); + + test('should return undefined when git diffs are disabled', () => { + const context = createMockContext({ gitDiffEnabled: false }); + const result = generateGitDiffsSection(context); + + expect(result).toBeUndefined(); + }); + }); + + describe('generateGitLogsSection', () => { + test('should generate git logs section when enabled', () => { + const context = createMockContext({ + gitLogEnabled: true, + gitLogCommits: [ + { + date: '2024-01-15', + message: 'feat: add new feature', + files: ['src/index.ts', 'src/feature.ts'], + }, + { + date: '2024-01-14', + message: 'fix: bug fix', + files: ['src/utils.ts'], + }, + ], + }); + const result = generateGitLogsSection(context); + + expect(result).not.toBeUndefined(); + expect(result).toContain('# Git Logs'); + expect(result).toContain('## Commit: 2024-01-15'); + expect(result).toContain('**Message:** feat: add new feature'); + expect(result).toContain('**Files:**'); + expect(result).toContain('- src/index.ts'); + expect(result).toContain('- src/feature.ts'); + expect(result).toContain('## Commit: 2024-01-14'); + expect(result).toContain('fix: bug fix'); + }); + + test('should return undefined when git logs are disabled', () => { + const context = createMockContext({ gitLogEnabled: false }); + const result = generateGitLogsSection(context); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when git log commits are undefined', () => { + const context = createMockContext({ + gitLogEnabled: true, + gitLogCommits: undefined, + }); + const result = generateGitLogsSection(context); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/testing/testUtils.ts b/tests/testing/testUtils.ts index 939d543c9..ffd9dc5a4 100644 --- a/tests/testing/testUtils.ts +++ b/tests/testing/testUtils.ts @@ -14,7 +14,7 @@ type DeepPartial = { export const createMockConfig = (config: DeepPartial = {}): RepomixConfigMerged => { return { - cwd: process.cwd(), + cwd: config.cwd ?? process.cwd(), input: { ...defaultConfig.input, ...config.input, @@ -41,6 +41,9 @@ export const createMockConfig = (config: DeepPartial = {}): ...defaultConfig.tokenCount, ...config.tokenCount, }, + // CLI-only optional properties + ...(config.skillGenerate !== undefined && { skillGenerate: config.skillGenerate }), + ...(config.remoteUrl !== undefined && { remoteUrl: config.remoteUrl }), }; }; From e137b56081fc3e981f20acda810e1f9c8f45e212 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:12:51 +0900 Subject: [PATCH 04/30] feat(skill): Add interactive skill location selection Add @clack/prompts-based selection for skill output location: - Personal Skills (~/.claude/skills/) - default, available across all projects - Project Skills (.claude/skills/) - shared with team via git Features: - Interactive prompt to choose skill location - Overwrite confirmation when skill directory already exists - Works with both local and remote repositories --- src/cli/actions/defaultAction.ts | 25 +++++- src/cli/actions/remoteAction.ts | 27 ++++-- src/cli/actions/skillPrompts.ts | 89 +++++++++++++++++++ .../actions/workers/defaultActionWorker.ts | 19 +++- src/cli/types.ts | 1 + src/core/packager.ts | 12 ++- src/core/packager/writeSkillOutput.ts | 8 +- .../workers/defaultActionWorker.test.ts | 34 +++++-- tests/core/packager/writeSkillOutput.test.ts | 39 ++++---- 9 files changed, 204 insertions(+), 50 deletions(-) create mode 100644 src/cli/actions/skillPrompts.ts diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 5e04c4e86..cebf95c41 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { loadFileConfig, mergeConfigs } from '../../config/configLoad.js'; import { type RepomixConfigCli, @@ -7,6 +8,7 @@ import { repomixConfigCliSchema, } from '../../config/configSchema.js'; import { readFilePathsFromStdin } from '../../core/file/fileStdin.js'; +import { generateDefaultSkillName } from '../../core/output/skill/skillUtils.js'; import type { PackResult } from '../../core/packager.js'; import { RepomixError, rethrowValidationErrorIfZodError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; @@ -15,6 +17,7 @@ import { initTaskRunner } from '../../shared/processConcurrency.js'; import { reportResults } from '../cliReport.js'; import type { CliOptions } from '../types.js'; import { runMigrationAction } from './migrationAction.js'; +import { promptSkillLocation } from './skillPrompts.js'; import type { DefaultActionTask, DefaultActionWorkerResult, @@ -55,7 +58,8 @@ export const runDefaultAction = async ( logger.trace('Merged config:', config); - // Validate skill generation options + // Validate skill generation options and prompt for location + let skillDir: string | undefined; if (config.skillGenerate !== undefined) { if (config.output.stdout) { throw new RepomixError( @@ -67,6 +71,24 @@ export const runDefaultAction = async ( '--skill-generate cannot be used with --copy. Skill output is a directory and cannot be copied to clipboard.', ); } + + // Use pre-computed skillDir if provided (from remoteAction), otherwise prompt + if (cliOptions.skillDir) { + skillDir = cliOptions.skillDir; + } else { + // Resolve skill name + const skillName = + typeof config.skillGenerate === 'string' + ? config.skillGenerate + : generateDefaultSkillName( + directories.map((d) => path.resolve(cwd, d)), + config.remoteUrl, + ); + + // Prompt for skill location (personal or project) + const promptResult = await promptSkillLocation(skillName, cwd); + skillDir = promptResult.skillDir; + } } // Handle stdin processing in main process (before worker creation) @@ -104,6 +126,7 @@ export const runDefaultAction = async ( config, cliOptions, stdinFilePaths, + skillDir, }; // Run the task in worker (spinner is handled inside worker) diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index c3efa7adc..95a162769 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -7,11 +7,13 @@ import { downloadGitHubArchive, isArchiveDownloadSupported } from '../../core/gi import { getRemoteRefs } from '../../core/git/gitRemoteHandle.js'; import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../core/git/gitRemoteParse.js'; import { isGitInstalled } from '../../core/git/gitRepositoryHandle.js'; +import { generateDefaultSkillName } from '../../core/output/skill/skillUtils.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { Spinner } from '../cliSpinner.js'; import type { CliOptions } from '../types.js'; import { type DefaultActionRunnerResult, runDefaultAction } from './defaultAction.js'; +import { promptSkillLocation, type SkillLocation } from './skillPrompts.js'; export const runRemoteAction = async ( repoUrl: string, @@ -88,19 +90,34 @@ export const runRemoteAction = async ( downloadMethod = 'git'; } + // For skill generation, prompt for location using current directory (not temp directory) + let skillDir: string | undefined; + let skillLocation: SkillLocation | undefined; + if (cliOptions.skillGenerate !== undefined) { + const skillName = + typeof cliOptions.skillGenerate === 'string' + ? cliOptions.skillGenerate + : generateDefaultSkillName([tempDirPath], repoUrl); + + const promptResult = await promptSkillLocation(skillName, process.cwd()); + skillDir = promptResult.skillDir; + skillLocation = promptResult.location; + } + // Run the default action on the downloaded/cloned repository // Pass the remote URL for skill name auto-generation - const optionsWithRemoteUrl = { ...cliOptions, remoteUrl: repoUrl }; + const optionsWithRemoteUrl = { ...cliOptions, remoteUrl: repoUrl, skillDir }; result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithRemoteUrl); // Copy output to current directory // Skip copy for stdout mode (output goes directly to stdout) - // For skill generation, copy the skill directory instead of a single file + // For skill generation with project location, copy the skill directory + // For personal location, skill is already written to ~/.claude/skills/ if (!cliOptions.stdout) { - if (result.config.skillGenerate !== undefined) { - // Copy skill directory to current directory + if (result.config.skillGenerate !== undefined && skillLocation === 'project') { + // Copy skill directory to current directory (only for project skills) await copySkillOutputToCurrentDirectory(tempDirPath, process.cwd()); - } else { + } else if (result.config.skillGenerate === undefined) { await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath); } } diff --git a/src/cli/actions/skillPrompts.ts b/src/cli/actions/skillPrompts.ts new file mode 100644 index 000000000..0509ae0d6 --- /dev/null +++ b/src/cli/actions/skillPrompts.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import * as prompts from '@clack/prompts'; +import pc from 'picocolors'; + +export type SkillLocation = 'personal' | 'project'; + +export interface SkillPromptResult { + location: SkillLocation; + skillDir: string; +} + +const onCancelOperation = () => { + prompts.cancel('Skill generation cancelled.'); + process.exit(0); +}; + +/** + * Get the base directory for skills based on location type. + */ +export const getSkillBaseDir = (cwd: string, location: SkillLocation): string => { + if (location === 'personal') { + return path.join(os.homedir(), '.claude', 'skills'); + } + return path.join(cwd, '.claude', 'skills'); +}; + +/** + * Prompt user for skill location and handle overwrite confirmation. + */ +export const promptSkillLocation = async ( + skillName: string, + cwd: string, + deps = { + select: prompts.select, + confirm: prompts.confirm, + isCancel: prompts.isCancel, + access: fs.access, + }, +): Promise => { + // Step 1: Ask for skill location + const location = await deps.select({ + message: 'Where would you like to save the skill?', + options: [ + { + value: 'personal' as SkillLocation, + label: 'Personal Skills', + hint: '~/.claude/skills/ - Available across all projects', + }, + { + value: 'project' as SkillLocation, + label: 'Project Skills', + hint: '.claude/skills/ - Shared with team via git', + }, + ], + initialValue: 'personal' as SkillLocation, + }); + + if (deps.isCancel(location)) { + onCancelOperation(); + } + + const skillDir = path.join(getSkillBaseDir(cwd, location as SkillLocation), skillName); + + // Step 2: Check if directory exists and ask for overwrite + let dirExists = false; + try { + await deps.access(skillDir); + dirExists = true; + } catch { + // Directory doesn't exist + } + + if (dirExists) { + const overwrite = await deps.confirm({ + message: `Skill directory already exists: ${pc.yellow(skillDir)}\nDo you want to overwrite it?`, + }); + + if (deps.isCancel(overwrite) || !overwrite) { + onCancelOperation(); + } + } + + return { + location: location as SkillLocation, + skillDir, + }; +}; diff --git a/src/cli/actions/workers/defaultActionWorker.ts b/src/cli/actions/workers/defaultActionWorker.ts index 3d4c03576..6a4a71ca8 100644 --- a/src/cli/actions/workers/defaultActionWorker.ts +++ b/src/cli/actions/workers/defaultActionWorker.ts @@ -15,6 +15,7 @@ export interface DefaultActionTask { config: RepomixConfigMerged; cliOptions: CliOptions; stdinFilePaths?: string[]; + skillDir?: string; } export interface PingTask { @@ -44,7 +45,7 @@ async function defaultActionWorker( } // At this point, task is guaranteed to be DefaultActionTask - const { directories, cwd, config, cliOptions, stdinFilePaths } = task; + const { directories, cwd, config, cliOptions, stdinFilePaths, skillDir } = task; logger.trace('Worker: Using pre-loaded config:', config); @@ -55,6 +56,8 @@ async function defaultActionWorker( let packResult: PackResult; try { + const packOptions = skillDir ? { skillDir } : {}; + if (stdinFilePaths) { // Handle stdin processing with file paths from main process // File paths were already read from stdin in the main process @@ -69,14 +72,22 @@ async function defaultActionWorker( }, {}, stdinFilePaths, + packOptions, ); } else { // Handle directory processing const targetPaths = directories.map((directory) => path.resolve(cwd, directory)); - packResult = await pack(targetPaths, config, (message) => { - spinner.update(message); - }); + packResult = await pack( + targetPaths, + config, + (message) => { + spinner.update(message); + }, + {}, + undefined, + packOptions, + ); } spinner.succeed('Packing completed successfully!'); diff --git a/src/cli/types.ts b/src/cli/types.ts index bac708d24..a1ca1b582 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -58,6 +58,7 @@ export interface CliOptions extends OptionValues { // Skill Generation skillGenerate?: string | boolean; + skillDir?: string; // Pre-computed skill directory (used internally for remote repos) // Other Options topFilesLen?: number; diff --git a/src/core/packager.ts b/src/core/packager.ts index f4631c14c..1786e785f 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -51,12 +51,17 @@ const defaultDeps = { getGitLogs, }; +export interface PackOptions { + skillDir?: string; +} + export const pack = async ( rootDirs: string[], config: RepomixConfigMerged, progressCallback: RepomixProgressCallback = () => {}, overrideDeps: Partial = {}, explicitFiles?: string[], + options: PackOptions = {}, ): Promise => { const deps = { ...defaultDeps, @@ -127,7 +132,7 @@ export const pack = async ( let output: string; // Check if skill generation is requested - if (config.skillGenerate !== undefined) { + if (config.skillGenerate !== undefined && options.skillDir) { // Resolve skill name: use provided name or auto-generate const skillName = typeof config.skillGenerate === 'string' @@ -163,9 +168,8 @@ export const pack = async ( const skillOutput = deps.generateSkillMdFromReferences(skillReferencesResult, skillMetrics.totalTokens); progressCallback('Writing skill output...'); - await withMemoryLogging('Write Skill Output', () => - deps.writeSkillOutput(skillOutput, skillReferencesResult.skillName, config.cwd), - ); + const skillDir = options.skillDir; + await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(skillOutput, skillDir)); // Use files section for final metrics (most representative of content size) output = skillOutput.references.files; diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index 9714502d4..03170b606 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -3,12 +3,10 @@ import path from 'node:path'; import { RepomixError } from '../../shared/errorHandle.js'; import type { SkillOutputResult } from '../output/outputGenerate.js'; -const SKILL_DIR_NAME = '.claude/skills'; - /** * Writes skill output to the filesystem. * Creates the directory structure: - * .claude/skills// + * / * ├── SKILL.md * └── references/ * ├── summary.md @@ -19,14 +17,12 @@ const SKILL_DIR_NAME = '.claude/skills'; */ export const writeSkillOutput = async ( output: SkillOutputResult, - skillName: string, - cwd: string, + skillDir: string, deps = { mkdir: fs.mkdir, writeFile: fs.writeFile, }, ): Promise => { - const skillDir = path.join(cwd, SKILL_DIR_NAME, skillName); const referencesDir = path.join(skillDir, 'references'); try { diff --git a/tests/cli/actions/workers/defaultActionWorker.test.ts b/tests/cli/actions/workers/defaultActionWorker.test.ts index fb5861edc..e395acc3a 100644 --- a/tests/cli/actions/workers/defaultActionWorker.test.ts +++ b/tests/cli/actions/workers/defaultActionWorker.test.ts @@ -157,6 +157,9 @@ describe('defaultActionWorker', () => { [path.resolve('/test/project', 'src'), path.resolve('/test/project', 'tests')], mockConfig, expect.any(Function), + {}, + undefined, + {}, ); expect(result).toEqual({ packResult: mockPackResult, @@ -176,7 +179,14 @@ describe('defaultActionWorker', () => { const result = (await defaultActionWorker(task)) as DefaultActionWorkerResult; - expect(mockPack).toHaveBeenCalledWith([path.resolve('/test/project', '.')], mockConfig, expect.any(Function)); + expect(mockPack).toHaveBeenCalledWith( + [path.resolve('/test/project', '.')], + mockConfig, + expect.any(Function), + {}, + undefined, + {}, + ); expect(result).toEqual({ packResult: mockPackResult, config: mockConfig, @@ -195,7 +205,7 @@ describe('defaultActionWorker', () => { await defaultActionWorker(task); - expect(mockPack).toHaveBeenCalledWith([], mockConfig, expect.any(Function)); + expect(mockPack).toHaveBeenCalledWith([], mockConfig, expect.any(Function), {}, undefined, {}); }); }); @@ -213,10 +223,14 @@ describe('defaultActionWorker', () => { const result = (await defaultActionWorker(task)) as DefaultActionWorkerResult; - expect(mockPack).toHaveBeenCalledWith(['/test/project'], mockConfig, expect.any(Function), {}, [ - 'file1.txt', - 'file2.txt', - ]); + expect(mockPack).toHaveBeenCalledWith( + ['/test/project'], + mockConfig, + expect.any(Function), + {}, + ['file1.txt', 'file2.txt'], + {}, + ); expect(result).toEqual({ packResult: mockPackResult, config: mockConfig, @@ -236,7 +250,7 @@ describe('defaultActionWorker', () => { await defaultActionWorker(task); - expect(mockPack).toHaveBeenCalledWith(['/test/project'], mockConfig, expect.any(Function), {}, ['file1.txt']); + expect(mockPack).toHaveBeenCalledWith(['/test/project'], mockConfig, expect.any(Function), {}, ['file1.txt'], {}); }); }); @@ -340,6 +354,9 @@ describe('defaultActionWorker', () => { ], mockConfig, expect.any(Function), + {}, + undefined, + {}, ); }); @@ -359,6 +376,9 @@ describe('defaultActionWorker', () => { [path.resolve('/test/project', '/absolute/path1'), path.resolve('/test/project', '/absolute/path2')], mockConfig, expect.any(Function), + {}, + undefined, + {}, ); }); }); diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/packager/writeSkillOutput.test.ts index be84c2e5f..f5e4186e0 100644 --- a/tests/core/packager/writeSkillOutput.test.ts +++ b/tests/core/packager/writeSkillOutput.test.ts @@ -16,43 +16,38 @@ describe('writeSkillOutput', () => { }, }; - const skillName = 'test-skill'; - const cwd = '/test/project'; + const skillDir = '/test/project/.claude/skills/test-skill'; - const result = await writeSkillOutput(output, skillName, cwd, { + const result = await writeSkillOutput(output, skillDir, { mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, }); // Check references directory was created (includes skill directory with recursive: true) - expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', skillName, 'references'), { + expect(mockMkdir).toHaveBeenCalledWith(path.join(skillDir, 'references'), { recursive: true, }); // Check files were written + expect(mockWriteFile).toHaveBeenCalledWith(path.join(skillDir, 'SKILL.md'), output.skillMd, 'utf-8'); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'SKILL.md'), - output.skillMd, - 'utf-8', - ); - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'summary.md'), + path.join(skillDir, 'references', 'summary.md'), output.references.summary, 'utf-8', ); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'structure.md'), + path.join(skillDir, 'references', 'structure.md'), output.references.structure, 'utf-8', ); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'files.md'), + path.join(skillDir, 'references', 'files.md'), output.references.files, 'utf-8', ); // Check return value - expect(result).toBe(path.join(cwd, '.claude/skills', skillName)); + expect(result).toBe(skillDir); }); test('should write git-diffs.md and git-logs.md when provided', async () => { @@ -70,28 +65,27 @@ describe('writeSkillOutput', () => { }, }; - const skillName = 'test-skill'; - const cwd = '/test/project'; + const skillDir = '/test/project/.claude/skills/test-skill'; - await writeSkillOutput(output, skillName, cwd, { + await writeSkillOutput(output, skillDir, { mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, }); // Check git files were written expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'git-diffs.md'), + path.join(skillDir, 'references', 'git-diffs.md'), output.references.gitDiffs, 'utf-8', ); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(cwd, '.claude/skills', skillName, 'references', 'git-logs.md'), + path.join(skillDir, 'references', 'git-logs.md'), output.references.gitLogs, 'utf-8', ); }); - test('should handle skill names with special characters', async () => { + test('should handle skill directories with various paths', async () => { const mockMkdir = vi.fn().mockResolvedValue(undefined); const mockWriteFile = vi.fn().mockResolvedValue(undefined); @@ -104,15 +98,14 @@ describe('writeSkillOutput', () => { }, }; - const skillName = 'my-special-skill'; - const cwd = '/test/project'; + const skillDir = '/home/user/.claude/skills/my-special-skill'; - await writeSkillOutput(output, skillName, cwd, { + await writeSkillOutput(output, skillDir, { mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, }); - expect(mockMkdir).toHaveBeenCalledWith(path.join(cwd, '.claude/skills', 'my-special-skill', 'references'), { + expect(mockMkdir).toHaveBeenCalledWith(path.join(skillDir, 'references'), { recursive: true, }); }); From 46094b8cd82b8f7d1af1de8f9712f9e3eaa6ed36 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:29:27 +0900 Subject: [PATCH 05/30] fix(cli): Show correct skill output path in summary When Personal Skills location is selected, the summary was showing a relative path (.claude/skills/) instead of the actual path (~/.claude/skills/). Now passes the computed skillDir to reportResults. --- src/cli/actions/defaultAction.ts | 2 +- src/cli/cliReport.ts | 25 +++++++++++-------- .../defaultAction.tokenCountTree.test.ts | 4 +++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index cebf95c41..c610ca5d1 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -133,7 +133,7 @@ export const runDefaultAction = async ( const result = (await taskRunner.run(task)) as DefaultActionWorkerResult; // Report results in main process - reportResults(cwd, result.packResult, result.config); + reportResults(cwd, result.packResult, result.config, { skillDir }); return { packResult: result.packResult, diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index e01550d96..2abba82cd 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -2,16 +2,24 @@ import path from 'node:path'; import pc from 'picocolors'; import type { RepomixConfigMerged } from '../config/configSchema.js'; import type { SkippedFileInfo } from '../core/file/fileCollect.js'; -import { generateDefaultSkillName } from '../core/output/skill/skillUtils.js'; import type { PackResult } from '../core/packager.js'; import type { SuspiciousFileResult } from '../core/security/securityCheck.js'; import { logger } from '../shared/logger.js'; import { reportTokenCountTree } from './reporters/tokenCountTreeReporter.js'; +export interface ReportOptions { + skillDir?: string; +} + /** * Reports the results of packing operation including top files, security check, summary, and completion. */ -export const reportResults = (cwd: string, packResult: PackResult, config: RepomixConfigMerged): void => { +export const reportResults = ( + cwd: string, + packResult: PackResult, + config: RepomixConfigMerged, + options: ReportOptions = {}, +): void => { logger.log(''); if (config.output.topFilesLength > 0) { @@ -41,13 +49,13 @@ export const reportResults = (cwd: string, packResult: PackResult, config: Repom reportSkippedFiles(cwd, packResult.skippedFiles); logger.log(''); - reportSummary(packResult, config); + reportSummary(packResult, config, options); logger.log(''); reportCompletion(); }; -export const reportSummary = (packResult: PackResult, config: RepomixConfigMerged) => { +export const reportSummary = (packResult: PackResult, config: RepomixConfigMerged, options: ReportOptions = {}) => { let securityCheckMessage = ''; if (config.security.enableSecurityCheck) { if (packResult.suspiciousFilesResults.length > 0) { @@ -68,13 +76,8 @@ export const reportSummary = (packResult: PackResult, config: RepomixConfigMerge logger.log(`${pc.white(' Total Chars:')} ${pc.white(packResult.totalCharacters.toLocaleString())} chars`); // Show skill output path or regular output path - if (config.skillGenerate !== undefined) { - const skillName = - typeof config.skillGenerate === 'string' - ? config.skillGenerate - : generateDefaultSkillName([config.cwd], config.remoteUrl); - const skillPath = `.claude/skills/${skillName}/`; - logger.log(`${pc.white(' Output:')} ${pc.white(skillPath)} ${pc.dim('(skill directory)')}`); + if (config.skillGenerate !== undefined && options.skillDir) { + logger.log(`${pc.white(' Output:')} ${pc.white(options.skillDir)} ${pc.dim('(skill directory)')}`); } else { logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); } diff --git a/tests/cli/actions/defaultAction.tokenCountTree.test.ts b/tests/cli/actions/defaultAction.tokenCountTree.test.ts index 99d589cef..e7fb150f2 100644 --- a/tests/cli/actions/defaultAction.tokenCountTree.test.ts +++ b/tests/cli/actions/defaultAction.tokenCountTree.test.ts @@ -109,6 +109,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: true, }), }), + { skillDir: undefined }, ); }); @@ -125,6 +126,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: false, }), }), + { skillDir: undefined }, ); }); @@ -155,6 +157,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: true, }), }), + { skillDir: undefined }, ); }); @@ -185,6 +188,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: 50, }), }), + { skillDir: undefined }, ); }); }); From c5fb07cdbecae2ce149e5ec6fbebbb0bb263f401 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:32:32 +0900 Subject: [PATCH 06/30] fix(cli): Show relative path for skill output when under cwd When the skill directory is under the current working directory, show a relative path (e.g., .claude/skills/my-skill) instead of an absolute path for better readability. --- src/cli/cliReport.ts | 13 ++++++++++--- tests/cli/cliReport.test.ts | 12 ++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index 2abba82cd..b8c4fc4d5 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -49,13 +49,18 @@ export const reportResults = ( reportSkippedFiles(cwd, packResult.skippedFiles); logger.log(''); - reportSummary(packResult, config, options); + reportSummary(cwd, packResult, config, options); logger.log(''); reportCompletion(); }; -export const reportSummary = (packResult: PackResult, config: RepomixConfigMerged, options: ReportOptions = {}) => { +export const reportSummary = ( + cwd: string, + packResult: PackResult, + config: RepomixConfigMerged, + options: ReportOptions = {}, +) => { let securityCheckMessage = ''; if (config.security.enableSecurityCheck) { if (packResult.suspiciousFilesResults.length > 0) { @@ -77,7 +82,9 @@ export const reportSummary = (packResult: PackResult, config: RepomixConfigMerge // Show skill output path or regular output path if (config.skillGenerate !== undefined && options.skillDir) { - logger.log(`${pc.white(' Output:')} ${pc.white(options.skillDir)} ${pc.dim('(skill directory)')}`); + // Show relative path if under cwd, otherwise absolute path + const displayPath = options.skillDir.startsWith(cwd) ? path.relative(cwd, options.skillDir) : options.skillDir; + logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)} ${pc.dim('(skill directory)')}`); } else { logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); } diff --git a/tests/cli/cliReport.test.ts b/tests/cli/cliReport.test.ts index 5ef888603..b0f482bbe 100644 --- a/tests/cli/cliReport.test.ts +++ b/tests/cli/cliReport.test.ts @@ -49,7 +49,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('1 suspicious file(s) detected and excluded')); }); @@ -76,7 +76,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Git diffs included')); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('50 tokens')); @@ -104,7 +104,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('No git diffs included')); }); @@ -131,7 +131,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Git logs included')); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('30 tokens')); @@ -159,7 +159,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('No git logs included')); }); @@ -185,7 +185,7 @@ describe('cliReport', () => { skippedFiles: [], }; - reportSummary(packResult, config); + reportSummary('/test/project', packResult, config); expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Security check disabled')); }); From 7b82617d24bd3016c4bc2fb3afd7eaeb9f814780 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:33:49 +0900 Subject: [PATCH 07/30] refactor(cli): Apply relative path display to regular output Apply the same relative/absolute path display logic to regular repomix output, not just skill output. Shows relative path when the output file is under the current working directory. --- src/cli/cliReport.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index b8c4fc4d5..75357c905 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -81,12 +81,14 @@ export const reportSummary = ( logger.log(`${pc.white(' Total Chars:')} ${pc.white(packResult.totalCharacters.toLocaleString())} chars`); // Show skill output path or regular output path + // Use relative path if under cwd, otherwise absolute path if (config.skillGenerate !== undefined && options.skillDir) { - // Show relative path if under cwd, otherwise absolute path const displayPath = options.skillDir.startsWith(cwd) ? path.relative(cwd, options.skillDir) : options.skillDir; logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)} ${pc.dim('(skill directory)')}`); } else { - logger.log(`${pc.white(' Output:')} ${pc.white(config.output.filePath)}`); + const outputPath = path.resolve(cwd, config.output.filePath); + const displayPath = outputPath.startsWith(cwd) ? path.relative(cwd, outputPath) : outputPath; + logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)}`); } logger.log(`${pc.white(' Security:')} ${pc.white(securityCheckMessage)}`); From 686cebed20e6a3b6edd723189a8576377a29c6c4 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:35:13 +0900 Subject: [PATCH 08/30] style(cli): Improve skill overwrite confirmation message format Change message structure to show the question first, then the path on a separate line with dimmed styling for better readability. --- src/cli/actions/skillPrompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/actions/skillPrompts.ts b/src/cli/actions/skillPrompts.ts index 0509ae0d6..7c5a164f2 100644 --- a/src/cli/actions/skillPrompts.ts +++ b/src/cli/actions/skillPrompts.ts @@ -74,7 +74,7 @@ export const promptSkillLocation = async ( if (dirExists) { const overwrite = await deps.confirm({ - message: `Skill directory already exists: ${pc.yellow(skillDir)}\nDo you want to overwrite it?`, + message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${skillDir}`)}`, }); if (deps.isCancel(overwrite) || !overwrite) { From 6f671733bb638b8132c7761e5bbb0fd0553eea11 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:37:42 +0900 Subject: [PATCH 09/30] refactor(cli): Extract getDisplayPath helper function Extract the relative/absolute path display logic into a reusable getDisplayPath function and use it in both cliReport.ts and skillPrompts.ts for consistent path display. --- src/cli/actions/skillPrompts.ts | 4 +++- src/cli/cliReport.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli/actions/skillPrompts.ts b/src/cli/actions/skillPrompts.ts index 7c5a164f2..49f47c4ad 100644 --- a/src/cli/actions/skillPrompts.ts +++ b/src/cli/actions/skillPrompts.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import * as prompts from '@clack/prompts'; import pc from 'picocolors'; +import { getDisplayPath } from '../cliReport.js'; export type SkillLocation = 'personal' | 'project'; @@ -73,8 +74,9 @@ export const promptSkillLocation = async ( } if (dirExists) { + const displayPath = getDisplayPath(skillDir, cwd); const overwrite = await deps.confirm({ - message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${skillDir}`)}`, + message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${displayPath}`)}`, }); if (deps.isCancel(overwrite) || !overwrite) { diff --git a/src/cli/cliReport.ts b/src/cli/cliReport.ts index 75357c905..edac717db 100644 --- a/src/cli/cliReport.ts +++ b/src/cli/cliReport.ts @@ -7,6 +7,13 @@ import type { SuspiciousFileResult } from '../core/security/securityCheck.js'; import { logger } from '../shared/logger.js'; import { reportTokenCountTree } from './reporters/tokenCountTreeReporter.js'; +/** + * Convert an absolute path to a relative path if it's under cwd, otherwise return as-is. + */ +export const getDisplayPath = (absolutePath: string, cwd: string): string => { + return absolutePath.startsWith(cwd) ? path.relative(cwd, absolutePath) : absolutePath; +}; + export interface ReportOptions { skillDir?: string; } @@ -81,13 +88,12 @@ export const reportSummary = ( logger.log(`${pc.white(' Total Chars:')} ${pc.white(packResult.totalCharacters.toLocaleString())} chars`); // Show skill output path or regular output path - // Use relative path if under cwd, otherwise absolute path if (config.skillGenerate !== undefined && options.skillDir) { - const displayPath = options.skillDir.startsWith(cwd) ? path.relative(cwd, options.skillDir) : options.skillDir; + const displayPath = getDisplayPath(options.skillDir, cwd); logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)} ${pc.dim('(skill directory)')}`); } else { const outputPath = path.resolve(cwd, config.output.filePath); - const displayPath = outputPath.startsWith(cwd) ? path.relative(cwd, outputPath) : outputPath; + const displayPath = getDisplayPath(outputPath, cwd); logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)}`); } logger.log(`${pc.white(' Security:')} ${pc.white(securityCheckMessage)}`); From c57dd552fca64ca522fecb297c88cf3f25b8e6b2 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:42:12 +0900 Subject: [PATCH 10/30] feat(skill): Add line counts to directory structure in skill output Display the number of lines next to each file in the directory structure section of skill output. This helps when using grep to find specific files by line count. Example: src/ index.ts (42 lines) utils.ts (128 lines) --- src/core/file/fileTreeGenerate.ts | 41 +++++++++++++++++++ src/core/output/outputGenerate.ts | 10 +++++ src/core/output/outputGeneratorTypes.ts | 1 + .../output/skill/skillSectionGenerators.ts | 8 +++- .../skill/skillSectionGenerators.test.ts | 4 ++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/core/file/fileTreeGenerate.ts b/src/core/file/fileTreeGenerate.ts index 419cbca1d..437613b8c 100644 --- a/src/core/file/fileTreeGenerate.ts +++ b/src/core/file/fileTreeGenerate.ts @@ -68,7 +68,48 @@ export const treeToString = (node: TreeNode, prefix = ''): string => { return result; }; +/** + * Converts a tree to string with line counts for files. + * @param node The tree node to convert + * @param lineCounts Map of file paths to line counts + * @param prefix Current indentation prefix + * @param currentPath Current path being built (for looking up line counts) + */ +export const treeToStringWithLineCounts = ( + node: TreeNode, + lineCounts: Record, + prefix = '', + currentPath = '', +): string => { + sortTreeNodes(node); + let result = ''; + + for (const child of node.children) { + const childPath = currentPath ? `${currentPath}/${child.name}` : child.name; + + if (child.isDirectory) { + result += `${prefix}${child.name}/\n`; + result += treeToStringWithLineCounts(child, lineCounts, `${prefix} `, childPath); + } else { + const lineCount = lineCounts[childPath]; + const lineCountSuffix = lineCount !== undefined ? ` (${lineCount} lines)` : ''; + result += `${prefix}${child.name}${lineCountSuffix}\n`; + } + } + + return result; +}; + export const generateTreeString = (files: string[], emptyDirPaths: string[] = []): string => { const tree = generateFileTree(files, emptyDirPaths); return treeToString(tree).trim(); }; + +export const generateTreeStringWithLineCounts = ( + files: string[], + lineCounts: Record, + emptyDirPaths: string[] = [], +): string => { + const tree = generateFileTree(files, emptyDirPaths); + return treeToStringWithLineCounts(tree, lineCounts).trim(); +}; diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 15f1400b0..08d5a7566 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -39,6 +39,15 @@ const calculateMarkdownDelimiter = (files: ReadonlyArray): string return '`'.repeat(Math.max(3, maxBackticks + 1)); }; +const calculateFileLineCounts = (processedFiles: ProcessedFile[]): Record => { + const lineCounts: Record = {}; + for (const file of processedFiles) { + // Count lines by splitting on newlines + lineCounts[file.path] = file.content.split('\n').length; + } + return lineCounts; +}; + const createRenderContext = (outputGeneratorContext: OutputGeneratorContext): RenderContext => { return { generationHeader: generateHeader(outputGeneratorContext.config, outputGeneratorContext.generationDate), @@ -53,6 +62,7 @@ const createRenderContext = (outputGeneratorContext: OutputGeneratorContext): Re instruction: outputGeneratorContext.instruction, treeString: outputGeneratorContext.treeString, processedFiles: outputGeneratorContext.processedFiles, + fileLineCounts: calculateFileLineCounts(outputGeneratorContext.processedFiles), fileSummaryEnabled: outputGeneratorContext.config.output.fileSummary, directoryStructureEnabled: outputGeneratorContext.config.output.directoryStructure, filesEnabled: outputGeneratorContext.config.output.files, diff --git a/src/core/output/outputGeneratorTypes.ts b/src/core/output/outputGeneratorTypes.ts index ffa2c565f..270a022c0 100644 --- a/src/core/output/outputGeneratorTypes.ts +++ b/src/core/output/outputGeneratorTypes.ts @@ -23,6 +23,7 @@ export interface RenderContext { readonly instruction: string; readonly treeString: string; readonly processedFiles: ReadonlyArray; + readonly fileLineCounts: Record; readonly fileSummaryEnabled: boolean; readonly directoryStructureEnabled: boolean; readonly filesEnabled: boolean; diff --git a/src/core/output/skill/skillSectionGenerators.ts b/src/core/output/skill/skillSectionGenerators.ts index 4ae6f9e55..11a7d9055 100644 --- a/src/core/output/skill/skillSectionGenerators.ts +++ b/src/core/output/skill/skillSectionGenerators.ts @@ -1,4 +1,5 @@ import Handlebars from 'handlebars'; +import { generateTreeStringWithLineCounts } from '../../file/fileTreeGenerate.js'; import type { RenderContext } from '../outputGeneratorTypes.js'; import { getLanguageFromFilePath } from '../outputStyleUtils.js'; @@ -29,12 +30,17 @@ export const generateSummarySection = (context: RenderContext): string => { /** * Generates the directory structure section for skill output. + * Includes line counts for each file to aid in grep searches. */ export const generateStructureSection = (context: RenderContext): string => { if (!context.directoryStructureEnabled) { return ''; } + // Generate tree string with line counts for skill output + const filePaths = context.processedFiles.map((f) => f.path); + const treeStringWithLineCounts = generateTreeStringWithLineCounts(filePaths, context.fileLineCounts); + const template = Handlebars.compile(`# Directory Structure \`\`\` @@ -42,7 +48,7 @@ export const generateStructureSection = (context: RenderContext): string => { \`\`\` `); - return template(context).trim(); + return template({ treeString: treeStringWithLineCounts }).trim(); }; /** diff --git a/tests/core/output/skill/skillSectionGenerators.test.ts b/tests/core/output/skill/skillSectionGenerators.test.ts index 28082e2a0..b2f17c217 100644 --- a/tests/core/output/skill/skillSectionGenerators.test.ts +++ b/tests/core/output/skill/skillSectionGenerators.test.ts @@ -21,6 +21,10 @@ const createMockContext = (overrides: Partial = {}): RenderContex { path: 'src/index.ts', content: 'console.log("hello");' }, { path: 'src/utils.ts', content: 'export const sum = (a, b) => a + b;' }, ], + fileLineCounts: { + 'src/index.ts': 1, + 'src/utils.ts': 1, + }, fileSummaryEnabled: true, directoryStructureEnabled: true, filesEnabled: true, From 88a20b9367b73ffa740867f20d5ab1a568b0071b Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:43:06 +0900 Subject: [PATCH 11/30] docs(skill): Update SKILL.md to explain grep-friendly features - Mention line counts in directory structure for easier file identification - Explain that files use `## File: ` format for direct grep searches --- src/core/output/skill/skillStyle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index d9fa6b38a..1e256e30e 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -35,10 +35,10 @@ This skill provides reference to the {{{projectName}}} codebase. Overview of the packed content, including purpose, file format description, and important notes about excluded files. ### Directory Structure (\`references/structure.md\`) -Tree view of the project's directory structure. Start here to understand the overall layout. +Tree view of the project's directory structure with line counts for each file. Start here to understand the overall layout. Line counts help identify file sizes when using grep or read tools. ### Files (\`references/files.md\`) -Complete file contents. Each file includes its path and full source code. +Complete file contents. Each file is marked with \`## File: \` header, allowing direct grep searches by file path. {{#if hasGitDiffs}} ### Git Diffs (\`references/git-diffs.md\`) From e40c5f3d63e9b1d3598d82b662333667465495f7 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:49:36 +0900 Subject: [PATCH 12/30] refactor(skill): Remove git diffs and logs from skill output Skill output is designed for referencing external codebases (e.g., GitHub repositories) as persistent skills. Git diffs (uncommitted changes) and git logs (commit history) are not meaningful in this context since: - Remote repositories have no uncommitted changes - Commit history is less relevant for reference skills This simplifies the skill output to include only: - SKILL.md - references/summary.md - references/structure.md - references/files.md --- src/core/output/outputGenerate.ts | 8 ----- src/core/output/skill/skillStyle.ts | 22 ------------ src/core/packager/writeSkillOutput.ts | 12 +------ tests/core/output/skill/skillStyle.test.ts | 30 +---------------- tests/core/packager/writeSkillOutput.test.ts | 35 -------------------- 5 files changed, 2 insertions(+), 105 deletions(-) diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 08d5a7566..fbf4017cc 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -24,8 +24,6 @@ import { getPlainTemplate } from './outputStyles/plainStyle.js'; import { getXmlTemplate } from './outputStyles/xmlStyle.js'; import { generateFilesSection, - generateGitDiffsSection, - generateGitLogsSection, generateStructureSection, generateSummarySection, } from './skill/skillSectionGenerators.js'; @@ -376,8 +374,6 @@ export interface SkillReferences { summary: string; structure: string; files: string; - gitDiffs?: string; - gitLogs?: string; } /** @@ -452,8 +448,6 @@ export const generateSkillReferences = async ( summary: generateSummarySection(renderContext), structure: generateStructureSection(renderContext), files: generateFilesSection(renderContext), - gitDiffs: generateGitDiffsSection(renderContext), - gitLogs: generateGitLogsSection(renderContext), }; return { @@ -479,8 +473,6 @@ export const generateSkillMdFromReferences = ( projectName: referencesResult.projectName, totalFiles: referencesResult.totalFiles, totalTokens, - hasGitDiffs: !!referencesResult.references.gitDiffs, - hasGitLogs: !!referencesResult.references.gitLogs, }); return { diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index 1e256e30e..6ce9cfd37 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -6,8 +6,6 @@ export interface SkillRenderContext { projectName: string; totalFiles: number; totalTokens: number; - hasGitDiffs?: boolean; - hasGitLogs?: boolean; } /** @@ -40,31 +38,11 @@ Tree view of the project's directory structure with line counts for each file. S ### Files (\`references/files.md\`) Complete file contents. Each file is marked with \`## File: \` header, allowing direct grep searches by file path. -{{#if hasGitDiffs}} -### Git Diffs (\`references/git-diffs.md\`) -Current uncommitted changes (working tree and staged). - -{{/if}} -{{#if hasGitLogs}} -### Git Logs (\`references/git-logs.md\`) -Recent commit history with dates, messages, and changed files. - -{{/if}} ## How to Use 1. **Understand the layout**: Read \`structure.md\` first to see the project organization 2. **Find specific code**: Search in \`files.md\` for functions, classes, or specific implementations 3. **Get context**: Check \`summary.md\` for information about what's included and excluded -{{#if hasGitDiffs}} -4. **Check recent changes**: Review \`git-diffs.md\` for uncommitted modifications -{{/if}} -{{#if hasGitLogs}} -{{#if hasGitDiffs}} -5. **Review history**: See \`git-logs.md\` for recent commit history -{{else}} -4. **Review history**: See \`git-logs.md\` for recent commit history -{{/if}} -{{/if}} `; }; diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index 03170b606..a01df9d8e 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -11,9 +11,7 @@ import type { SkillOutputResult } from '../output/outputGenerate.js'; * └── references/ * ├── summary.md * ├── structure.md - * ├── files.md - * ├── git-diffs.md (if enabled) - * └── git-logs.md (if enabled) + * └── files.md */ export const writeSkillOutput = async ( output: SkillOutputResult, @@ -38,14 +36,6 @@ export const writeSkillOutput = async ( await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); - // Write optional git files - if (output.references.gitDiffs) { - await deps.writeFile(path.join(referencesDir, 'git-diffs.md'), output.references.gitDiffs, 'utf-8'); - } - if (output.references.gitLogs) { - await deps.writeFile(path.join(referencesDir, 'git-logs.md'), output.references.gitLogs, 'utf-8'); - } - return skillDir; } catch (error) { const nodeError = error as NodeJS.ErrnoException; diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index 6a339c45e..a7fd05d19 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -84,35 +84,7 @@ describe('skillStyle', () => { expect(result).toContain('`references/files.md`'); }); - test('should include git sections when hasGitDiffs is true', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - hasGitDiffs: true, - }; - - const result = generateSkillMd(context); - expect(result).toContain('references/git-diffs.md'); - }); - - test('should include git log section when hasGitLogs is true', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - hasGitLogs: true, - }; - - const result = generateSkillMd(context); - expect(result).toContain('references/git-logs.md'); - }); - - test('should not include git sections when not enabled', () => { + test('should not include git sections (skill output is for reference codebases)', () => { const context = { skillName: 'test-skill', skillDescription: 'Test description', diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/packager/writeSkillOutput.test.ts index f5e4186e0..d2307d42d 100644 --- a/tests/core/packager/writeSkillOutput.test.ts +++ b/tests/core/packager/writeSkillOutput.test.ts @@ -50,41 +50,6 @@ describe('writeSkillOutput', () => { expect(result).toBe(skillDir); }); - test('should write git-diffs.md and git-logs.md when provided', async () => { - const mockMkdir = vi.fn().mockResolvedValue(undefined); - const mockWriteFile = vi.fn().mockResolvedValue(undefined); - - const output = { - skillMd: '---\nname: test-skill\n---\n# Test Skill', - references: { - summary: '# Summary', - structure: '# Structure', - files: '# Files', - gitDiffs: '# Git Diffs\n\n```diff\n+added line\n```', - gitLogs: '# Git Logs\n\n## Commit: abc123\nFix bug', - }, - }; - - const skillDir = '/test/project/.claude/skills/test-skill'; - - await writeSkillOutput(output, skillDir, { - mkdir: mockMkdir as unknown as typeof import('node:fs/promises').mkdir, - writeFile: mockWriteFile as unknown as typeof import('node:fs/promises').writeFile, - }); - - // Check git files were written - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(skillDir, 'references', 'git-diffs.md'), - output.references.gitDiffs, - 'utf-8', - ); - expect(mockWriteFile).toHaveBeenCalledWith( - path.join(skillDir, 'references', 'git-logs.md'), - output.references.gitLogs, - 'utf-8', - ); - }); - test('should handle skill directories with various paths', async () => { const mockMkdir = vi.fn().mockResolvedValue(undefined); const mockWriteFile = vi.fn().mockResolvedValue(undefined); From 9916cec9761e0435ce356587fc52a41f7252e436 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 18:52:15 +0900 Subject: [PATCH 13/30] docs(skill): Improve SKILL.md template for better usability Restructure SKILL.md to be more practical and scannable: - Add reference files table with descriptions - Include Quick Reference section with concrete examples - Show exact grep patterns for finding files - Display file format examples for understanding structure - Move statistics inline with header for brevity --- src/core/output/skill/skillStyle.ts | 44 ++++++++++++++-------- tests/core/output/skill/skillStyle.test.ts | 17 ++++----- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index 6ce9cfd37..6b2646469 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -20,29 +20,41 @@ description: {{{skillDescription}}} # {{{projectName}}} Codebase Reference -This skill provides reference to the {{{projectName}}} codebase. - -## Statistics - -- Total Files: {{{totalFiles}}} -- Total Tokens: {{{totalTokens}}} +This skill provides reference to the {{{projectName}}} codebase ({{{totalFiles}}} files, {{{totalTokens}}} tokens). ## Reference Files -### Summary (\`references/summary.md\`) -Overview of the packed content, including purpose, file format description, and important notes about excluded files. +| File | Description | +|------|-------------| +| \`references/structure.md\` | Directory tree with line counts per file | +| \`references/files.md\` | All file contents with \`## File: \` headers | +| \`references/summary.md\` | Purpose, format description, and excluded files info | -### Directory Structure (\`references/structure.md\`) -Tree view of the project's directory structure with line counts for each file. Start here to understand the overall layout. Line counts help identify file sizes when using grep or read tools. +## Quick Reference -### Files (\`references/files.md\`) -Complete file contents. Each file is marked with \`## File: \` header, allowing direct grep searches by file path. +### Finding Files by Path +Grep in \`files.md\` using the header format: +\`\`\` +## File: src/components/Button.tsx +\`\`\` -## How to Use +### Understanding Project Structure +Check \`structure.md\` for the directory tree. Each file shows its line count: +\`\`\` +src/ + index.ts (42 lines) + utils/ + helpers.ts (128 lines) +\`\`\` -1. **Understand the layout**: Read \`structure.md\` first to see the project organization -2. **Find specific code**: Search in \`files.md\` for functions, classes, or specific implementations -3. **Get context**: Check \`summary.md\` for information about what's included and excluded +### Reading File Contents +Files in \`files.md\` are formatted as: +\`\`\` +## File: +\\\`\`\` + +\\\`\`\` +\`\`\` `; }; diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index a7fd05d19..952669f5a 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -12,17 +12,16 @@ describe('skillStyle', () => { expect(template).toContain('references/'); }); - test('should include statistics section', () => { + test('should include reference files table', () => { const template = getSkillTemplate(); - expect(template).toContain('## Statistics'); - expect(template).toContain('Total Files'); - expect(template).toContain('Total Tokens'); + expect(template).toContain('## Reference Files'); + expect(template).toContain('| File | Description |'); }); - test('should include how to use section', () => { + test('should include quick reference section', () => { const template = getSkillTemplate(); - expect(template).toContain('## How to Use'); - expect(template).toContain('Understand the layout'); + expect(template).toContain('## Quick Reference'); + expect(template).toContain('Finding Files by Path'); }); test('should reference multiple files', () => { @@ -52,8 +51,8 @@ describe('skillStyle', () => { // Check content expect(result).toContain('# My Project Codebase Reference'); - expect(result).toContain('Total Files: 42'); - expect(result).toContain('Total Tokens: 12345'); + expect(result).toContain('42 files'); + expect(result).toContain('12345 tokens'); }); test('should end with newline', () => { From 8aa10c5ebc0c02aae809693d4a00405c8b599b48 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 19:02:18 +0900 Subject: [PATCH 14/30] docs(skill): Improve SKILL.md template with overview and use cases Add Overview, Common Use Cases, and Tips sections to make the template more actionable and instantly understandable. Remove redundant File Format section (delegated to summary.md). Changes: - Add Overview section explaining when to use this skill - Add Common Use Cases with concrete scenarios (understand feature, debug, find usages) - Add Tips section for effective usage - Remove File Format section to reduce redundancy --- src/core/output/skill/skillStyle.ts | 71 ++++++++++++++++------ tests/core/output/skill/skillStyle.test.ts | 21 +++++-- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index 6b2646469..e5b10405b 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -20,26 +20,30 @@ description: {{{skillDescription}}} # {{{projectName}}} Codebase Reference -This skill provides reference to the {{{projectName}}} codebase ({{{totalFiles}}} files, {{{totalTokens}}} tokens). +{{{totalFiles}}} files | {{{totalTokens}}} tokens -## Reference Files +## Overview -| File | Description | -|------|-------------| +Use this skill when you need to: +- Understand project structure and file organization +- Find where specific functionality is implemented +- Read source code for any file +- Search for code patterns or keywords + +## Files + +| File | Contents | +|------|----------| | \`references/structure.md\` | Directory tree with line counts per file | -| \`references/files.md\` | All file contents with \`## File: \` headers | -| \`references/summary.md\` | Purpose, format description, and excluded files info | +| \`references/files.md\` | All file contents (header: \`## File: \`) | +| \`references/summary.md\` | Purpose and format explanation | -## Quick Reference +## How to Use -### Finding Files by Path -Grep in \`files.md\` using the header format: -\`\`\` -## File: src/components/Button.tsx -\`\`\` +### 1. Find file locations + +Check \`structure.md\` for the directory tree: -### Understanding Project Structure -Check \`structure.md\` for the directory tree. Each file shows its line count: \`\`\` src/ index.ts (42 lines) @@ -47,14 +51,41 @@ src/ helpers.ts (128 lines) \`\`\` -### Reading File Contents -Files in \`files.md\` are formatted as: +### 2. Read file contents + +Grep in \`files.md\` for the file path: + +\`\`\` +## File: src/utils/helpers.ts +\`\`\` + +### 3. Search for code + +Grep in \`files.md\` for keywords: + \`\`\` -## File: -\\\`\`\` - -\\\`\`\` +function calculateTotal \`\`\` + +## Common Use Cases + +**Understand a feature:** +1. Search \`structure.md\` for related file names +2. Read the main implementation file in \`files.md\` +3. Search for imports/references to trace dependencies + +**Debug an error:** +1. Grep the error message or class name in \`files.md\` +2. Check line counts in \`structure.md\` to find large files + +**Find all usages:** +1. Grep function or variable name in \`files.md\` + +## Tips + +- Use line counts in \`structure.md\` to estimate file complexity +- Search \`## File:\` pattern to jump between files +- Check \`summary.md\` for excluded files and format details `; }; diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index 952669f5a..008967725 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -12,16 +12,25 @@ describe('skillStyle', () => { expect(template).toContain('references/'); }); - test('should include reference files table', () => { + test('should include files table with contents column', () => { const template = getSkillTemplate(); - expect(template).toContain('## Reference Files'); - expect(template).toContain('| File | Description |'); + expect(template).toContain('## Files'); + expect(template).toContain('| File | Contents |'); }); - test('should include quick reference section', () => { + test('should include how to use section with numbered steps', () => { const template = getSkillTemplate(); - expect(template).toContain('## Quick Reference'); - expect(template).toContain('Finding Files by Path'); + expect(template).toContain('## How to Use'); + expect(template).toContain('### 1. Find file locations'); + expect(template).toContain('### 2. Read file contents'); + expect(template).toContain('### 3. Search for code'); + }); + + test('should include overview and common use cases', () => { + const template = getSkillTemplate(); + expect(template).toContain('## Overview'); + expect(template).toContain('## Common Use Cases'); + expect(template).toContain('## Tips'); }); test('should reference multiple files', () => { From 2a4209d42cf8212ea9d84a18c96afaecedbfa100 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Sun, 7 Dec 2025 23:29:15 +0900 Subject: [PATCH 15/30] feat(skill): Add tech-stack detection and file statistics to skill output Add new features to improve skill output for understanding codebases: - Add tech-stack.md: Auto-detect languages, frameworks, and dependencies from package.json, requirements.txt, go.mod, Cargo.toml, etc. - Add file statistics section to SKILL.md with language breakdown and largest files list - Rename structure.md to project-structure.md for clarity - Add total lines count to SKILL.md header --- src/core/output/outputGenerate.ts | 23 +- src/core/output/skill/skillStatistics.ts | 196 +++++++++ src/core/output/skill/skillStyle.ts | 23 +- src/core/output/skill/skillTechStack.ts | 405 ++++++++++++++++++ src/core/packager/writeSkillOutput.ts | 12 +- .../core/output/skill/skillStatistics.test.ts | 179 ++++++++ tests/core/output/skill/skillStyle.test.ts | 76 ++-- .../core/output/skill/skillTechStack.test.ts | 196 +++++++++ 8 files changed, 1069 insertions(+), 41 deletions(-) create mode 100644 src/core/output/skill/skillStatistics.ts create mode 100644 src/core/output/skill/skillTechStack.ts create mode 100644 tests/core/output/skill/skillStatistics.test.ts create mode 100644 tests/core/output/skill/skillTechStack.test.ts diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index fbf4017cc..7770c54ec 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -27,7 +27,9 @@ import { generateStructureSection, generateSummarySection, } from './skill/skillSectionGenerators.js'; +import { calculateStatistics, generateStatisticsSection } from './skill/skillStatistics.js'; import { generateSkillMd } from './skill/skillStyle.js'; +import { detectTechStack, generateTechStackMd } from './skill/skillTechStack.js'; import { generateProjectName, generateSkillDescription, validateSkillName } from './skill/skillUtils.js'; const calculateMarkdownDelimiter = (files: ReadonlyArray): string => { @@ -374,6 +376,7 @@ export interface SkillReferences { summary: string; structure: string; files: string; + techStack?: string; } /** @@ -385,6 +388,9 @@ export interface SkillReferencesResult { projectName: string; skillDescription: string; totalFiles: number; + totalLines: number; + statisticsSection: string; + hasTechStack: boolean; } /** @@ -396,7 +402,7 @@ export interface SkillOutputResult { } /** - * Generates skill reference files (summary, structure, files, git-diffs, git-logs). + * Generates skill reference files (summary, structure, files, tech-stack). * This is the first step - call this, calculate metrics, then call generateSkillMdFromReferences. */ export const generateSkillReferences = async ( @@ -443,11 +449,20 @@ export const generateSkillReferences = async ( ); const renderContext = createRenderContext(outputGeneratorContext); + // Calculate statistics + const statistics = calculateStatistics(sortedProcessedFiles, renderContext.fileLineCounts); + const statisticsSection = generateStatisticsSection(statistics); + + // Detect tech stack + const techStack = detectTechStack(sortedProcessedFiles); + const techStackMd = techStack ? generateTechStackMd(techStack) : undefined; + // Generate each section separately const references: SkillReferences = { summary: generateSummarySection(renderContext), structure: generateStructureSection(renderContext), files: generateFilesSection(renderContext), + techStack: techStackMd, }; return { @@ -456,6 +471,9 @@ export const generateSkillReferences = async ( projectName, skillDescription, totalFiles: sortedProcessedFiles.length, + totalLines: statistics.totalLines, + statisticsSection, + hasTechStack: techStack !== null, }; }; @@ -472,7 +490,10 @@ export const generateSkillMdFromReferences = ( skillDescription: referencesResult.skillDescription, projectName: referencesResult.projectName, totalFiles: referencesResult.totalFiles, + totalLines: referencesResult.totalLines, totalTokens, + statisticsSection: referencesResult.statisticsSection, + hasTechStack: referencesResult.hasTechStack, }); return { diff --git a/src/core/output/skill/skillStatistics.ts b/src/core/output/skill/skillStatistics.ts new file mode 100644 index 000000000..a9966e847 --- /dev/null +++ b/src/core/output/skill/skillStatistics.ts @@ -0,0 +1,196 @@ +import path from 'node:path'; +import type { ProcessedFile } from '../../file/fileTypes.js'; + +interface FileTypeStats { + extension: string; + language: string; + fileCount: number; + lineCount: number; +} + +interface StatisticsInfo { + totalFiles: number; + totalLines: number; + byFileType: FileTypeStats[]; + largestFiles: Array<{ path: string; lines: number }>; +} + +// Map extensions to language names +const EXTENSION_TO_LANGUAGE: Record = { + // JavaScript/TypeScript + '.js': 'JavaScript', + '.jsx': 'JavaScript (JSX)', + '.ts': 'TypeScript', + '.tsx': 'TypeScript (TSX)', + '.mjs': 'JavaScript (ESM)', + '.cjs': 'JavaScript (CJS)', + + // Web + '.html': 'HTML', + '.htm': 'HTML', + '.css': 'CSS', + '.scss': 'SCSS', + '.sass': 'Sass', + '.less': 'Less', + '.vue': 'Vue', + '.svelte': 'Svelte', + + // Data/Config + '.json': 'JSON', + '.yaml': 'YAML', + '.yml': 'YAML', + '.toml': 'TOML', + '.xml': 'XML', + '.ini': 'INI', + '.env': 'Environment', + + // Documentation + '.md': 'Markdown', + '.mdx': 'MDX', + '.rst': 'reStructuredText', + '.txt': 'Text', + + // Backend + '.py': 'Python', + '.rb': 'Ruby', + '.php': 'PHP', + '.java': 'Java', + '.kt': 'Kotlin', + '.kts': 'Kotlin Script', + '.scala': 'Scala', + '.go': 'Go', + '.rs': 'Rust', + '.c': 'C', + '.cpp': 'C++', + '.cc': 'C++', + '.h': 'C/C++ Header', + '.hpp': 'C++ Header', + '.cs': 'C#', + '.swift': 'Swift', + '.m': 'Objective-C', + '.mm': 'Objective-C++', + + // Shell/Scripts + '.sh': 'Shell', + '.bash': 'Bash', + '.zsh': 'Zsh', + '.fish': 'Fish', + '.ps1': 'PowerShell', + '.bat': 'Batch', + '.cmd': 'Batch', + + // Other + '.sql': 'SQL', + '.graphql': 'GraphQL', + '.gql': 'GraphQL', + '.proto': 'Protocol Buffers', + '.dockerfile': 'Dockerfile', + '.lua': 'Lua', + '.r': 'R', + '.ex': 'Elixir', + '.exs': 'Elixir Script', + '.erl': 'Erlang', + '.clj': 'Clojure', + '.hs': 'Haskell', + '.ml': 'OCaml', + '.nim': 'Nim', + '.zig': 'Zig', + '.dart': 'Dart', + '.v': 'V', + '.sol': 'Solidity', +}; + +/** + * Gets language name from file extension. + */ +const getLanguageFromExtension = (ext: string): string => { + return EXTENSION_TO_LANGUAGE[ext.toLowerCase()] || ext.slice(1).toUpperCase() || 'Unknown'; +}; + +/** + * Calculates statistics from processed files. + */ +export const calculateStatistics = ( + processedFiles: ProcessedFile[], + fileLineCounts: Record, +): StatisticsInfo => { + const statsByExt: Record = {}; + let totalLines = 0; + + // Calculate stats by extension + for (const file of processedFiles) { + const ext = path.extname(file.path).toLowerCase() || '(no ext)'; + const lines = fileLineCounts[file.path] || file.content.split('\n').length; + + if (!statsByExt[ext]) { + statsByExt[ext] = { fileCount: 0, lineCount: 0 }; + } + statsByExt[ext].fileCount++; + statsByExt[ext].lineCount += lines; + totalLines += lines; + } + + // Convert to array and sort by file count + const byFileType: FileTypeStats[] = Object.entries(statsByExt) + .map(([ext, stats]) => ({ + extension: ext, + language: ext === '(no ext)' ? 'No Extension' : getLanguageFromExtension(ext), + fileCount: stats.fileCount, + lineCount: stats.lineCount, + })) + .sort((a, b) => b.fileCount - a.fileCount); + + // Get largest files (top 5) + const largestFiles = processedFiles + .map((file) => ({ + path: file.path, + lines: fileLineCounts[file.path] || file.content.split('\n').length, + })) + .sort((a, b) => b.lines - a.lines) + .slice(0, 5); + + return { + totalFiles: processedFiles.length, + totalLines, + byFileType, + largestFiles, + }; +}; + +/** + * Generates statistics markdown table for SKILL.md. + */ +export const generateStatisticsSection = (stats: StatisticsInfo): string => { + const lines: string[] = ['## Statistics', '']; + + // Summary line + lines.push(`${stats.totalFiles} files | ${stats.totalLines.toLocaleString()} lines`); + lines.push(''); + + // File type table (top 10) + lines.push('| Language | Files | Lines |'); + lines.push('|----------|------:|------:|'); + + const topTypes = stats.byFileType.slice(0, 10); + for (const type of topTypes) { + lines.push(`| ${type.language} | ${type.fileCount} | ${type.lineCount.toLocaleString()} |`); + } + + if (stats.byFileType.length > 10) { + const otherFiles = stats.byFileType.slice(10).reduce((sum, t) => sum + t.fileCount, 0); + const otherLines = stats.byFileType.slice(10).reduce((sum, t) => sum + t.lineCount, 0); + lines.push(`| Other | ${otherFiles} | ${otherLines.toLocaleString()} |`); + } + + lines.push(''); + + // Largest files + if (stats.largestFiles.length > 0) { + lines.push('**Largest files:**'); + for (const file of stats.largestFiles) { + lines.push(`- \`${file.path}\` (${file.lines.toLocaleString()} lines)`); + } + } + + return lines.join('\n'); +}; diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index e5b10405b..da713c81a 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -5,7 +5,10 @@ export interface SkillRenderContext { skillDescription: string; projectName: string; totalFiles: number; + totalLines: number; totalTokens: number; + statisticsSection: string; + hasTechStack: boolean; } /** @@ -20,7 +23,7 @@ description: {{{skillDescription}}} # {{{projectName}}} Codebase Reference -{{{totalFiles}}} files | {{{totalTokens}}} tokens +{{{totalFiles}}} files | {{{totalLines}}} lines | {{{totalTokens}}} tokens ## Overview @@ -34,15 +37,20 @@ Use this skill when you need to: | File | Contents | |------|----------| -| \`references/structure.md\` | Directory tree with line counts per file | +| \`references/project-structure.md\` | Directory tree with line counts per file | | \`references/files.md\` | All file contents (header: \`## File: \`) | +{{#if hasTechStack}} +| \`references/tech-stack.md\` | Languages, frameworks, and dependencies | +{{/if}} | \`references/summary.md\` | Purpose and format explanation | +{{{statisticsSection}}} + ## How to Use ### 1. Find file locations -Check \`structure.md\` for the directory tree: +Check \`project-structure.md\` for the directory tree: \`\`\` src/ @@ -70,22 +78,25 @@ function calculateTotal ## Common Use Cases **Understand a feature:** -1. Search \`structure.md\` for related file names +1. Search \`project-structure.md\` for related file names 2. Read the main implementation file in \`files.md\` 3. Search for imports/references to trace dependencies **Debug an error:** 1. Grep the error message or class name in \`files.md\` -2. Check line counts in \`structure.md\` to find large files +2. Check line counts in \`project-structure.md\` to find large files **Find all usages:** 1. Grep function or variable name in \`files.md\` ## Tips -- Use line counts in \`structure.md\` to estimate file complexity +- Use line counts in \`project-structure.md\` to estimate file complexity - Search \`## File:\` pattern to jump between files - Check \`summary.md\` for excluded files and format details +{{#if hasTechStack}} +- Check \`tech-stack.md\` for languages, frameworks, and dependencies +{{/if}} `; }; diff --git a/src/core/output/skill/skillTechStack.ts b/src/core/output/skill/skillTechStack.ts new file mode 100644 index 000000000..955b3d02a --- /dev/null +++ b/src/core/output/skill/skillTechStack.ts @@ -0,0 +1,405 @@ +import type { ProcessedFile } from '../../file/fileTypes.js'; + +interface DependencyInfo { + name: string; + version?: string; + isDev?: boolean; +} + +interface TechStackInfo { + languages: string[]; + frameworks: string[]; + dependencies: DependencyInfo[]; + devDependencies: DependencyInfo[]; + packageManager?: string; +} + +// Dependency file patterns and their parsers +const DEPENDENCY_FILES: Record Partial }> = { + 'package.json': { language: 'Node.js', parser: parsePackageJson }, + 'requirements.txt': { language: 'Python', parser: parseRequirementsTxt }, + 'pyproject.toml': { language: 'Python', parser: parsePyprojectToml }, + Pipfile: { language: 'Python', parser: parsePipfile }, + 'go.mod': { language: 'Go', parser: parseGoMod }, + 'Cargo.toml': { language: 'Rust', parser: parseCargoToml }, + 'composer.json': { language: 'PHP', parser: parseComposerJson }, + Gemfile: { language: 'Ruby', parser: parseGemfile }, + 'pom.xml': { language: 'Java', parser: parsePomXml }, + 'build.gradle': { language: 'Java/Kotlin', parser: parseBuildGradle }, + 'build.gradle.kts': { language: 'Kotlin', parser: parseBuildGradle }, +}; + +function parsePackageJson(content: string): Partial { + try { + const pkg = JSON.parse(content); + const dependencies: DependencyInfo[] = []; + const devDependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse dependencies + if (pkg.dependencies) { + for (const [name, version] of Object.entries(pkg.dependencies)) { + dependencies.push({ name, version: String(version) }); + + // Detect frameworks + if (name === 'react' || name === 'react-dom') frameworks.push('React'); + if (name === 'vue') frameworks.push('Vue'); + if (name === 'next') frameworks.push('Next.js'); + if (name === 'nuxt') frameworks.push('Nuxt'); + if (name === '@angular/core') frameworks.push('Angular'); + if (name === 'express') frameworks.push('Express'); + if (name === 'fastify') frameworks.push('Fastify'); + if (name === 'hono') frameworks.push('Hono'); + if (name === 'svelte') frameworks.push('Svelte'); + } + } + + // Parse devDependencies + if (pkg.devDependencies) { + for (const [name, version] of Object.entries(pkg.devDependencies)) { + devDependencies.push({ name, version: String(version), isDev: true }); + + // Detect TypeScript + if (name === 'typescript') frameworks.push('TypeScript'); + } + } + + // Detect package manager + let packageManager: string | undefined; + if (pkg.packageManager) { + const pm = String(pkg.packageManager); + if (pm.startsWith('pnpm')) packageManager = 'pnpm'; + else if (pm.startsWith('yarn')) packageManager = 'yarn'; + else if (pm.startsWith('npm')) packageManager = 'npm'; + else if (pm.startsWith('bun')) packageManager = 'bun'; + } + + return { + dependencies, + devDependencies, + frameworks: [...new Set(frameworks)], + packageManager, + }; + } catch { + return {}; + } +} + +function parseRequirementsTxt(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue; + + // Parse package==version or package>=version format + const match = trimmed.match(/^([a-zA-Z0-9_-]+)([=<>!~]+)?(.+)?$/); + if (match) { + const name = match[1]; + const version = match[3]; + dependencies.push({ name, version }); + + // Detect frameworks + if (name.toLowerCase() === 'django') frameworks.push('Django'); + if (name.toLowerCase() === 'flask') frameworks.push('Flask'); + if (name.toLowerCase() === 'fastapi') frameworks.push('FastAPI'); + if (name.toLowerCase() === 'pytorch' || name.toLowerCase() === 'torch') frameworks.push('PyTorch'); + if (name.toLowerCase() === 'tensorflow') frameworks.push('TensorFlow'); + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePyprojectToml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Simple TOML parsing for dependencies + const depsMatch = content.match(/\[project\.dependencies\]([\s\S]*?)(?=\[|$)/); + if (depsMatch) { + const depsSection = depsMatch[1]; + const depLines = depsSection.match(/"([^"]+)"/g); + if (depLines) { + for (const dep of depLines) { + const name = dep + .replace(/"/g, '') + .split(/[=<>!~]/)[0] + .trim(); + if (name) { + dependencies.push({ name }); + if (name.toLowerCase() === 'django') frameworks.push('Django'); + if (name.toLowerCase() === 'flask') frameworks.push('Flask'); + if (name.toLowerCase() === 'fastapi') frameworks.push('FastAPI'); + } + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePipfile(content: string): Partial { + const dependencies: DependencyInfo[] = []; + + // Simple parsing for [packages] section + const packagesMatch = content.match(/\[packages\]([\s\S]*?)(?=\[|$)/); + if (packagesMatch) { + const lines = packagesMatch[1].split('\n'); + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/); + if (match) { + dependencies.push({ name: match[1] }); + } + } + } + + return { dependencies }; +} + +function parseGoMod(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse require block + const requireMatch = content.match(/require\s*\(([\s\S]*?)\)/); + if (requireMatch) { + const lines = requireMatch[1].split('\n'); + for (const line of lines) { + const match = line.trim().match(/^([^\s]+)\s+([^\s]+)/); + if (match) { + const name = match[1]; + const version = match[2]; + dependencies.push({ name, version }); + + if (name.includes('gin-gonic/gin')) frameworks.push('Gin'); + if (name.includes('labstack/echo')) frameworks.push('Echo'); + if (name.includes('gofiber/fiber')) frameworks.push('Fiber'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseCargoToml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse [dependencies] section + const depsMatch = content.match(/\[dependencies\]([\s\S]*?)(?=\[|$)/); + if (depsMatch) { + const lines = depsMatch[1].split('\n'); + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/); + if (match) { + const name = match[1]; + dependencies.push({ name }); + + if (name === 'actix-web') frameworks.push('Actix'); + if (name === 'axum') frameworks.push('Axum'); + if (name === 'rocket') frameworks.push('Rocket'); + if (name === 'tokio') frameworks.push('Tokio'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseComposerJson(content: string): Partial { + try { + const composer = JSON.parse(content); + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + if (composer.require) { + for (const [name, version] of Object.entries(composer.require)) { + if (name !== 'php') { + dependencies.push({ name, version: String(version) }); + + if (name.startsWith('laravel/')) frameworks.push('Laravel'); + if (name.startsWith('symfony/')) frameworks.push('Symfony'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; + } catch { + return {}; + } +} + +function parseGemfile(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + const gemMatches = content.matchAll(/gem\s+['"]([^'"]+)['"]/g); + for (const match of gemMatches) { + const name = match[1]; + dependencies.push({ name }); + + if (name === 'rails') frameworks.push('Rails'); + if (name === 'sinatra') frameworks.push('Sinatra'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePomXml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Simple XML parsing for dependencies + const depMatches = content.matchAll(/[\s\S]*?([^<]+)<\/artifactId>[\s\S]*?<\/dependency>/g); + for (const match of depMatches) { + const name = match[1]; + dependencies.push({ name }); + + if (name.includes('spring')) frameworks.push('Spring'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseBuildGradle(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse implementation/compile dependencies + const depMatches = content.matchAll(/(?:implementation|compile)\s*['"(]([^'"()]+)['"]/g); + for (const match of depMatches) { + const dep = match[1]; + const parts = dep.split(':'); + const name = parts.length >= 2 ? parts[1] : dep; + dependencies.push({ name }); + + if (dep.includes('spring')) frameworks.push('Spring'); + if (dep.includes('ktor')) frameworks.push('Ktor'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +/** + * Detects tech stack from processed files. + * Only checks root-level dependency files. + */ +export const detectTechStack = (processedFiles: ProcessedFile[]): TechStackInfo | null => { + const result: TechStackInfo = { + languages: [], + frameworks: [], + dependencies: [], + devDependencies: [], + }; + + let foundAny = false; + + for (const file of processedFiles) { + // Only check root-level files (no directory separator in path) + const fileName = file.path.split('/').pop() || file.path; + if (file.path !== fileName && !file.path.startsWith('./')) { + // Skip files in subdirectories + const dirDepth = file.path.split('/').length - 1; + if (dirDepth > 0) continue; + } + + const config = DEPENDENCY_FILES[fileName]; + if (config) { + foundAny = true; + result.languages.push(config.language); + + const parsed = config.parser(file.content); + if (parsed.dependencies) { + result.dependencies.push(...parsed.dependencies); + } + if (parsed.devDependencies) { + result.devDependencies.push(...parsed.devDependencies); + } + if (parsed.frameworks) { + result.frameworks.push(...parsed.frameworks); + } + if (parsed.packageManager) { + result.packageManager = parsed.packageManager; + } + } + } + + if (!foundAny) { + return null; + } + + // Deduplicate + result.languages = [...new Set(result.languages)]; + result.frameworks = [...new Set(result.frameworks)]; + + return result; +}; + +/** + * Generates tech-stack.md content from detected tech stack. + */ +export const generateTechStackMd = (techStack: TechStackInfo): string => { + const lines: string[] = ['# Tech Stack', '']; + + // Languages + if (techStack.languages.length > 0) { + lines.push('## Languages'); + lines.push(''); + for (const lang of techStack.languages) { + lines.push(`- ${lang}`); + } + lines.push(''); + } + + // Frameworks + if (techStack.frameworks.length > 0) { + lines.push('## Frameworks'); + lines.push(''); + for (const fw of techStack.frameworks) { + lines.push(`- ${fw}`); + } + lines.push(''); + } + + // Package Manager + if (techStack.packageManager) { + lines.push('## Package Manager'); + lines.push(''); + lines.push(`- ${techStack.packageManager}`); + lines.push(''); + } + + // Dependencies (limit to top 20 for readability) + if (techStack.dependencies.length > 0) { + lines.push('## Dependencies'); + lines.push(''); + const deps = techStack.dependencies.slice(0, 20); + for (const dep of deps) { + const version = dep.version ? ` (${dep.version})` : ''; + lines.push(`- ${dep.name}${version}`); + } + if (techStack.dependencies.length > 20) { + lines.push(`- ... and ${techStack.dependencies.length - 20} more`); + } + lines.push(''); + } + + // Dev Dependencies (limit to top 10) + if (techStack.devDependencies.length > 0) { + lines.push('## Dev Dependencies'); + lines.push(''); + const devDeps = techStack.devDependencies.slice(0, 10); + for (const dep of devDeps) { + const version = dep.version ? ` (${dep.version})` : ''; + lines.push(`- ${dep.name}${version}`); + } + if (techStack.devDependencies.length > 10) { + lines.push(`- ... and ${techStack.devDependencies.length - 10} more`); + } + lines.push(''); + } + + return lines.join('\n').trim(); +}; diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index a01df9d8e..2de687d4b 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -10,8 +10,9 @@ import type { SkillOutputResult } from '../output/outputGenerate.js'; * ├── SKILL.md * └── references/ * ├── summary.md - * ├── structure.md - * └── files.md + * ├── project-structure.md + * ├── files.md + * └── tech-stack.md (if available) */ export const writeSkillOutput = async ( output: SkillOutputResult, @@ -33,9 +34,14 @@ export const writeSkillOutput = async ( // Write reference files await deps.writeFile(path.join(referencesDir, 'summary.md'), output.references.summary, 'utf-8'); - await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'project-structure.md'), output.references.structure, 'utf-8'); await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); + // Write tech-stack.md if available + if (output.references.techStack) { + await deps.writeFile(path.join(referencesDir, 'tech-stack.md'), output.references.techStack, 'utf-8'); + } + return skillDir; } catch (error) { const nodeError = error as NodeJS.ErrnoException; diff --git a/tests/core/output/skill/skillStatistics.test.ts b/tests/core/output/skill/skillStatistics.test.ts new file mode 100644 index 000000000..e5baa7bd9 --- /dev/null +++ b/tests/core/output/skill/skillStatistics.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'vitest'; +import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; +import { calculateStatistics, generateStatisticsSection } from '../../../../src/core/output/skill/skillStatistics.js'; + +describe('skillStatistics', () => { + describe('calculateStatistics', () => { + test('should calculate statistics by file type', () => { + const files: ProcessedFile[] = [ + { path: 'src/index.ts', content: 'line1\nline2\nline3' }, + { path: 'src/utils.ts', content: 'line1\nline2' }, + { path: 'src/styles.css', content: 'line1' }, + { path: 'README.md', content: 'line1\nline2\nline3\nline4' }, + ]; + + const lineCounts = { + 'src/index.ts': 3, + 'src/utils.ts': 2, + 'src/styles.css': 1, + 'README.md': 4, + }; + + const result = calculateStatistics(files, lineCounts); + + expect(result.totalFiles).toBe(4); + expect(result.totalLines).toBe(10); + expect(result.byFileType.length).toBe(3); + + const tsStats = result.byFileType.find((s) => s.extension === '.ts'); + expect(tsStats?.fileCount).toBe(2); + expect(tsStats?.lineCount).toBe(5); + expect(tsStats?.language).toBe('TypeScript'); + + const cssStats = result.byFileType.find((s) => s.extension === '.css'); + expect(cssStats?.fileCount).toBe(1); + expect(cssStats?.lineCount).toBe(1); + expect(cssStats?.language).toBe('CSS'); + + const mdStats = result.byFileType.find((s) => s.extension === '.md'); + expect(mdStats?.fileCount).toBe(1); + expect(mdStats?.lineCount).toBe(4); + expect(mdStats?.language).toBe('Markdown'); + }); + + test('should return largest files sorted by line count', () => { + const files: ProcessedFile[] = [ + { path: 'small.ts', content: 'a' }, + { path: 'large.ts', content: 'a\nb\nc\nd\ne' }, + { path: 'medium.ts', content: 'a\nb\nc' }, + ]; + + const lineCounts = { + 'small.ts': 1, + 'large.ts': 5, + 'medium.ts': 3, + }; + + const result = calculateStatistics(files, lineCounts); + + expect(result.largestFiles[0].path).toBe('large.ts'); + expect(result.largestFiles[0].lines).toBe(5); + expect(result.largestFiles[1].path).toBe('medium.ts'); + expect(result.largestFiles[2].path).toBe('small.ts'); + }); + + test('should limit largest files to 5', () => { + const files: ProcessedFile[] = Array.from({ length: 10 }, (_, i) => ({ + path: `file${i}.ts`, + content: 'a'.repeat(i + 1), + })); + + const lineCounts = Object.fromEntries(files.map((f, i) => [f.path, i + 1])); + + const result = calculateStatistics(files, lineCounts); + + expect(result.largestFiles.length).toBe(5); + }); + + test('should sort file types by file count', () => { + const files: ProcessedFile[] = [ + { path: 'a.ts', content: 'a' }, + { path: 'b.ts', content: 'a' }, + { path: 'c.ts', content: 'a' }, + { path: 'x.js', content: 'a' }, + { path: 'y.css', content: 'a' }, + { path: 'z.css', content: 'a' }, + ]; + + const lineCounts = Object.fromEntries(files.map((f) => [f.path, 1])); + + const result = calculateStatistics(files, lineCounts); + + expect(result.byFileType[0].extension).toBe('.ts'); + expect(result.byFileType[0].fileCount).toBe(3); + expect(result.byFileType[1].extension).toBe('.css'); + expect(result.byFileType[1].fileCount).toBe(2); + }); + + test('should handle files without extension', () => { + const files: ProcessedFile[] = [ + { path: 'Dockerfile', content: 'FROM node' }, + { path: 'Makefile', content: 'all:' }, + ]; + + const lineCounts = { + Dockerfile: 1, + Makefile: 1, + }; + + const result = calculateStatistics(files, lineCounts); + + const noExtStats = result.byFileType.find((s) => s.extension === '(no ext)'); + expect(noExtStats?.fileCount).toBe(2); + expect(noExtStats?.language).toBe('No Extension'); + }); + }); + + describe('generateStatisticsSection', () => { + test('should generate statistics markdown', () => { + const stats = { + totalFiles: 10, + totalLines: 500, + byFileType: [ + { extension: '.ts', language: 'TypeScript', fileCount: 5, lineCount: 300 }, + { extension: '.js', language: 'JavaScript', fileCount: 3, lineCount: 150 }, + { extension: '.css', language: 'CSS', fileCount: 2, lineCount: 50 }, + ], + largestFiles: [ + { path: 'src/main.ts', lines: 200 }, + { path: 'src/utils.ts', lines: 100 }, + ], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('## Statistics'); + expect(result).toContain('10 files | 500 lines'); + expect(result).toContain('| Language | Files | Lines |'); + expect(result).toContain('| TypeScript | 5 | 300 |'); + expect(result).toContain('| JavaScript | 3 | 150 |'); + expect(result).toContain('| CSS | 2 | 50 |'); + expect(result).toContain('**Largest files:**'); + expect(result).toContain('`src/main.ts` (200 lines)'); + expect(result).toContain('`src/utils.ts` (100 lines)'); + }); + + test('should limit file types to 10 and show "Other" row', () => { + const stats = { + totalFiles: 50, + totalLines: 1000, + byFileType: Array.from({ length: 15 }, (_, i) => ({ + extension: `.ext${i}`, + language: `Language${i}`, + fileCount: 5 - Math.floor(i / 3), + lineCount: 100 - i * 5, + })), + largestFiles: [], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('| Other |'); + expect(result).not.toContain('Language14'); + }); + + test('should format large numbers with locale string', () => { + const stats = { + totalFiles: 100, + totalLines: 10000, + byFileType: [{ extension: '.ts', language: 'TypeScript', fileCount: 100, lineCount: 10000 }], + largestFiles: [{ path: 'big.ts', lines: 5000 }], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('10,000'); + expect(result).toContain('5,000'); + }); + }); +}); diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index 008967725..daf332623 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -36,20 +36,34 @@ describe('skillStyle', () => { test('should reference multiple files', () => { const template = getSkillTemplate(); expect(template).toContain('references/summary.md'); - expect(template).toContain('references/structure.md'); + expect(template).toContain('references/project-structure.md'); expect(template).toContain('references/files.md'); }); }); describe('generateSkillMd', () => { + const createTestContext = (overrides = {}) => ({ + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalLines: 100, + totalTokens: 100, + statisticsSection: '## Statistics\n\n1 files | 100 lines', + hasTechStack: false, + ...overrides, + }); + test('should generate SKILL.md with all fields', () => { - const context = { + const context = createTestContext({ skillName: 'my-project-skill', skillDescription: 'Reference codebase for My Project.', projectName: 'My Project', totalFiles: 42, + totalLines: 1000, totalTokens: 12345, - }; + statisticsSection: '## Statistics\n\n42 files | 1,000 lines', + }); const result = generateSkillMd(context); @@ -65,45 +79,45 @@ describe('skillStyle', () => { }); test('should end with newline', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result.endsWith('\n')).toBe(true); }); test('should include references to multiple files', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result).toContain('`references/summary.md`'); - expect(result).toContain('`references/structure.md`'); + expect(result).toContain('`references/project-structure.md`'); expect(result).toContain('`references/files.md`'); }); test('should not include git sections (skill output is for reference codebases)', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result).not.toContain('git-diffs.md'); expect(result).not.toContain('git-logs.md'); }); + + test('should include tech-stack reference when hasTechStack is true', () => { + const result = generateSkillMd(createTestContext({ hasTechStack: true })); + expect(result).toContain('`references/tech-stack.md`'); + }); + + test('should not include tech-stack reference when hasTechStack is false', () => { + const result = generateSkillMd(createTestContext({ hasTechStack: false })); + expect(result).not.toContain('tech-stack.md'); + }); + + test('should include statistics section', () => { + const result = generateSkillMd( + createTestContext({ + statisticsSection: '## Statistics\n\n10 files | 500 lines', + }), + ); + expect(result).toContain('## Statistics'); + }); + + test('should include total lines in header', () => { + const result = generateSkillMd(createTestContext({ totalLines: 5000 })); + expect(result).toContain('5000 lines'); + }); }); }); diff --git a/tests/core/output/skill/skillTechStack.test.ts b/tests/core/output/skill/skillTechStack.test.ts new file mode 100644 index 000000000..913585530 --- /dev/null +++ b/tests/core/output/skill/skillTechStack.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from 'vitest'; +import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; +import { detectTechStack, generateTechStackMd } from '../../../../src/core/output/skill/skillTechStack.js'; + +describe('skillTechStack', () => { + describe('detectTechStack', () => { + test('should detect Node.js from package.json', () => { + const files: ProcessedFile[] = [ + { + path: 'package.json', + content: JSON.stringify({ + dependencies: { + react: '^18.2.0', + express: '^4.18.0', + }, + devDependencies: { + typescript: '^5.0.0', + }, + }), + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Node.js'); + expect(result?.frameworks).toContain('React'); + expect(result?.frameworks).toContain('Express'); + expect(result?.frameworks).toContain('TypeScript'); + expect(result?.dependencies.length).toBeGreaterThan(0); + expect(result?.devDependencies.length).toBeGreaterThan(0); + }); + + test('should detect Python from requirements.txt', () => { + const files: ProcessedFile[] = [ + { + path: 'requirements.txt', + content: `django==4.2.0 +flask>=2.0.0 +fastapi +# comment +-r base.txt`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Python'); + expect(result?.frameworks).toContain('Django'); + expect(result?.frameworks).toContain('Flask'); + expect(result?.frameworks).toContain('FastAPI'); + }); + + test('should detect Go from go.mod', () => { + const files: ProcessedFile[] = [ + { + path: 'go.mod', + content: `module example.com/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/stretchr/testify v1.8.0 +)`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Go'); + expect(result?.frameworks).toContain('Gin'); + }); + + test('should detect Rust from Cargo.toml', () => { + const files: ProcessedFile[] = [ + { + path: 'Cargo.toml', + content: `[package] +name = "myproject" +version = "0.1.0" + +[dependencies] +actix-web = "4.0" +tokio = { version = "1.0", features = ["full"] }`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Rust'); + expect(result?.frameworks).toContain('Actix'); + expect(result?.frameworks).toContain('Tokio'); + }); + + test('should return null when no dependency files found', () => { + const files: ProcessedFile[] = [ + { path: 'src/index.ts', content: 'console.log("hello")' }, + { path: 'README.md', content: '# My Project' }, + ]; + + const result = detectTechStack(files); + expect(result).toBeNull(); + }); + + test('should ignore dependency files in subdirectories', () => { + const files: ProcessedFile[] = [ + { + path: 'packages/sub/package.json', + content: JSON.stringify({ dependencies: { lodash: '4.0.0' } }), + }, + ]; + + const result = detectTechStack(files); + expect(result).toBeNull(); + }); + + test('should detect package manager from packageManager field', () => { + const files: ProcessedFile[] = [ + { + path: 'package.json', + content: JSON.stringify({ + packageManager: 'pnpm@8.0.0', + dependencies: {}, + }), + }, + ]; + + const result = detectTechStack(files); + expect(result?.packageManager).toBe('pnpm'); + }); + }); + + describe('generateTechStackMd', () => { + test('should generate markdown with all sections', () => { + const techStack = { + languages: ['Node.js'], + frameworks: ['React', 'TypeScript'], + dependencies: [ + { name: 'react', version: '^18.2.0' }, + { name: 'react-dom', version: '^18.2.0' }, + ], + devDependencies: [{ name: 'typescript', version: '^5.0.0' }], + packageManager: 'npm', + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('# Tech Stack'); + expect(result).toContain('## Languages'); + expect(result).toContain('- Node.js'); + expect(result).toContain('## Frameworks'); + expect(result).toContain('- React'); + expect(result).toContain('- TypeScript'); + expect(result).toContain('## Package Manager'); + expect(result).toContain('- npm'); + expect(result).toContain('## Dependencies'); + expect(result).toContain('- react (^18.2.0)'); + expect(result).toContain('## Dev Dependencies'); + expect(result).toContain('- typescript (^5.0.0)'); + }); + + test('should limit dependencies to 20', () => { + const techStack = { + languages: ['Node.js'], + frameworks: [], + dependencies: Array.from({ length: 25 }, (_, i) => ({ name: `dep-${i}`, version: '1.0.0' })), + devDependencies: [], + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('... and 5 more'); + expect(result).not.toContain('dep-24'); + }); + + test('should handle empty sections', () => { + const techStack = { + languages: ['Node.js'], + frameworks: [], + dependencies: [], + devDependencies: [], + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('# Tech Stack'); + expect(result).toContain('## Languages'); + expect(result).not.toContain('## Frameworks'); + expect(result).not.toContain('## Dependencies'); + }); + }); +}); From b4f25a0f2bb16dc43b64417c965197aa96013db1 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 8 Dec 2025 00:00:45 +0900 Subject: [PATCH 16/30] refactor(skill): Pass skillName directly instead of remoteUrl in config Remove remoteUrl from config schema and pass pre-computed skillName through CLI options instead. This provides cleaner separation between configuration and runtime data. - Add generateDefaultSkillNameFromUrl() for remote repository names - Update generateDefaultSkillName() to only handle local directories - Pass skillName through DefaultActionTask to packager --- src/cli/actions/defaultAction.ts | 26 +++++++---------- src/cli/actions/remoteAction.ts | 13 +++++---- .../actions/workers/defaultActionWorker.ts | 5 ++-- src/cli/types.ts | 2 +- src/config/configSchema.ts | 2 -- src/core/output/skill/skillUtils.ts | 28 +++++++++---------- src/core/packager.ts | 8 +++--- tests/core/packager/writeSkillOutput.test.ts | 2 +- tests/testing/testUtils.ts | 1 - 9 files changed, 40 insertions(+), 47 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index c610ca5d1..45eff396b 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -49,16 +49,11 @@ export const runDefaultAction = async ( logger.trace('CLI config:', cliConfig); // Merge default, file, and CLI configs - let config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); - - // Add remoteUrl if provided (for skill name auto-generation in remote mode) - if (cliOptions.remoteUrl) { - config = { ...config, remoteUrl: cliOptions.remoteUrl }; - } - + const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig); logger.trace('Merged config:', config); // Validate skill generation options and prompt for location + let skillName: string | undefined; let skillDir: string | undefined; if (config.skillGenerate !== undefined) { if (config.output.stdout) { @@ -72,19 +67,17 @@ export const runDefaultAction = async ( ); } + // Resolve skill name: use pre-computed name (from remoteAction) or generate from directory + skillName = + cliOptions.skillName ?? + (typeof config.skillGenerate === 'string' + ? config.skillGenerate + : generateDefaultSkillName(directories.map((d) => path.resolve(cwd, d)))); + // Use pre-computed skillDir if provided (from remoteAction), otherwise prompt if (cliOptions.skillDir) { skillDir = cliOptions.skillDir; } else { - // Resolve skill name - const skillName = - typeof config.skillGenerate === 'string' - ? config.skillGenerate - : generateDefaultSkillName( - directories.map((d) => path.resolve(cwd, d)), - config.remoteUrl, - ); - // Prompt for skill location (personal or project) const promptResult = await promptSkillLocation(skillName, cwd); skillDir = promptResult.skillDir; @@ -126,6 +119,7 @@ export const runDefaultAction = async ( config, cliOptions, stdinFilePaths, + skillName, skillDir, }; diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 95a162769..1349b2a8d 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -7,7 +7,7 @@ import { downloadGitHubArchive, isArchiveDownloadSupported } from '../../core/gi import { getRemoteRefs } from '../../core/git/gitRemoteHandle.js'; import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../core/git/gitRemoteParse.js'; import { isGitInstalled } from '../../core/git/gitRepositoryHandle.js'; -import { generateDefaultSkillName } from '../../core/output/skill/skillUtils.js'; +import { generateDefaultSkillNameFromUrl } from '../../core/output/skill/skillUtils.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { Spinner } from '../cliSpinner.js'; @@ -91,13 +91,14 @@ export const runRemoteAction = async ( } // For skill generation, prompt for location using current directory (not temp directory) + let skillName: string | undefined; let skillDir: string | undefined; let skillLocation: SkillLocation | undefined; if (cliOptions.skillGenerate !== undefined) { - const skillName = + skillName = typeof cliOptions.skillGenerate === 'string' ? cliOptions.skillGenerate - : generateDefaultSkillName([tempDirPath], repoUrl); + : generateDefaultSkillNameFromUrl(repoUrl); const promptResult = await promptSkillLocation(skillName, process.cwd()); skillDir = promptResult.skillDir; @@ -105,9 +106,9 @@ export const runRemoteAction = async ( } // Run the default action on the downloaded/cloned repository - // Pass the remote URL for skill name auto-generation - const optionsWithRemoteUrl = { ...cliOptions, remoteUrl: repoUrl, skillDir }; - result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithRemoteUrl); + // Pass the pre-computed skill name and directory + const optionsWithSkill = { ...cliOptions, skillName, skillDir }; + result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithSkill); // Copy output to current directory // Skip copy for stdout mode (output goes directly to stdout) diff --git a/src/cli/actions/workers/defaultActionWorker.ts b/src/cli/actions/workers/defaultActionWorker.ts index 6a4a71ca8..12f806457 100644 --- a/src/cli/actions/workers/defaultActionWorker.ts +++ b/src/cli/actions/workers/defaultActionWorker.ts @@ -15,6 +15,7 @@ export interface DefaultActionTask { config: RepomixConfigMerged; cliOptions: CliOptions; stdinFilePaths?: string[]; + skillName?: string; skillDir?: string; } @@ -45,7 +46,7 @@ async function defaultActionWorker( } // At this point, task is guaranteed to be DefaultActionTask - const { directories, cwd, config, cliOptions, stdinFilePaths, skillDir } = task; + const { directories, cwd, config, cliOptions, stdinFilePaths, skillName, skillDir } = task; logger.trace('Worker: Using pre-loaded config:', config); @@ -56,7 +57,7 @@ async function defaultActionWorker( let packResult: PackResult; try { - const packOptions = skillDir ? { skillDir } : {}; + const packOptions = { ...(skillName && { skillName }), ...(skillDir && { skillDir }) }; if (stdinFilePaths) { // Handle stdin processing with file paths from main process diff --git a/src/cli/types.ts b/src/cli/types.ts index a1ca1b582..3c19df565 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -39,7 +39,6 @@ export interface CliOptions extends OptionValues { // Remote Repository Options remote?: string; remoteBranch?: string; - remoteUrl?: string; // The actual remote URL (for skill name auto-generation) // Configuration Options config?: string; @@ -58,6 +57,7 @@ export interface CliOptions extends OptionValues { // Skill Generation skillGenerate?: string | boolean; + skillName?: string; // Pre-computed skill name (used internally for remote repos) skillDir?: string; // Pre-computed skill directory (used internally for remote repos) // Other Options diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index c24188541..cffba8f70 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -149,8 +149,6 @@ export const repomixConfigMergedSchema = repomixConfigDefaultSchema .and( z.object({ cwd: z.string(), - // Remote URL for skill name auto-generation (set by remoteAction) - remoteUrl: z.string().optional(), }), ); diff --git a/src/core/output/skill/skillUtils.ts b/src/core/output/skill/skillUtils.ts index d82016bcb..f234cd8c7 100644 --- a/src/core/output/skill/skillUtils.ts +++ b/src/core/output/skill/skillUtils.ts @@ -84,22 +84,22 @@ export const extractRepoName = (url: string): string => { }; /** - * Generates a default skill name based on the context. - * - For remote repositories: repomix-reference- - * - For local directories: repomix-reference- + * Generates a default skill name from a remote URL. + * Returns: repomix-reference- */ -export const generateDefaultSkillName = (rootDirs: string[], remoteUrl?: string): string => { - let baseName: string; - - if (remoteUrl) { - // Extract repo name from remote URL - baseName = extractRepoName(remoteUrl); - } else { - // Use local directory name - const primaryDir = rootDirs[0] || '.'; - baseName = path.basename(path.resolve(primaryDir)); - } +export const generateDefaultSkillNameFromUrl = (remoteUrl: string): string => { + const baseName = extractRepoName(remoteUrl); + const skillName = `${SKILL_NAME_PREFIX}-${toKebabCase(baseName)}`; + return validateSkillName(skillName); +}; +/** + * Generates a default skill name from local directories. + * Returns: repomix-reference- + */ +export const generateDefaultSkillName = (rootDirs: string[]): string => { + const primaryDir = rootDirs[0] || '.'; + const baseName = path.basename(path.resolve(primaryDir)); const skillName = `${SKILL_NAME_PREFIX}-${toKebabCase(baseName)}`; return validateSkillName(skillName); }; diff --git a/src/core/packager.ts b/src/core/packager.ts index 1786e785f..3995e5aa5 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -52,6 +52,7 @@ const defaultDeps = { }; export interface PackOptions { + skillName?: string; skillDir?: string; } @@ -133,11 +134,10 @@ export const pack = async ( // Check if skill generation is requested if (config.skillGenerate !== undefined && options.skillDir) { - // Resolve skill name: use provided name or auto-generate + // Use pre-computed skill name or generate from directories const skillName = - typeof config.skillGenerate === 'string' - ? config.skillGenerate - : deps.generateDefaultSkillName(rootDirs, config.remoteUrl); + options.skillName ?? + (typeof config.skillGenerate === 'string' ? config.skillGenerate : deps.generateDefaultSkillName(rootDirs)); // Step 1: Generate skill references (summary, structure, files, git-diffs, git-logs) const skillReferencesResult = await withMemoryLogging('Generate Skill References', () => diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/packager/writeSkillOutput.test.ts index d2307d42d..bc44379f5 100644 --- a/tests/core/packager/writeSkillOutput.test.ts +++ b/tests/core/packager/writeSkillOutput.test.ts @@ -36,7 +36,7 @@ describe('writeSkillOutput', () => { 'utf-8', ); expect(mockWriteFile).toHaveBeenCalledWith( - path.join(skillDir, 'references', 'structure.md'), + path.join(skillDir, 'references', 'project-structure.md'), output.references.structure, 'utf-8', ); diff --git a/tests/testing/testUtils.ts b/tests/testing/testUtils.ts index ffd9dc5a4..ddf8a61ad 100644 --- a/tests/testing/testUtils.ts +++ b/tests/testing/testUtils.ts @@ -43,7 +43,6 @@ export const createMockConfig = (config: DeepPartial = {}): }, // CLI-only optional properties ...(config.skillGenerate !== undefined && { skillGenerate: config.skillGenerate }), - ...(config.remoteUrl !== undefined && { remoteUrl: config.remoteUrl }), }; }; From 975ffe62ebebf265bcefb1c32de514ea3a39d317 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 8 Dec 2025 00:05:51 +0900 Subject: [PATCH 17/30] refactor(cli): Move skillPrompts.ts to cli/prompts directory Move skill prompt utilities to a dedicated prompts directory for better organization, consistent with the reporters directory pattern. --- src/cli/actions/defaultAction.ts | 2 +- src/cli/actions/remoteAction.ts | 2 +- src/cli/{actions => prompts}/skillPrompts.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/cli/{actions => prompts}/skillPrompts.ts (100%) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 45eff396b..ce228a1f8 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -15,9 +15,9 @@ import { logger } from '../../shared/logger.js'; import { splitPatterns } from '../../shared/patternUtils.js'; import { initTaskRunner } from '../../shared/processConcurrency.js'; import { reportResults } from '../cliReport.js'; +import { promptSkillLocation } from '../prompts/skillPrompts.js'; import type { CliOptions } from '../types.js'; import { runMigrationAction } from './migrationAction.js'; -import { promptSkillLocation } from './skillPrompts.js'; import type { DefaultActionTask, DefaultActionWorkerResult, diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 1349b2a8d..b431f05ff 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -11,9 +11,9 @@ import { generateDefaultSkillNameFromUrl } from '../../core/output/skill/skillUt import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { Spinner } from '../cliSpinner.js'; +import { promptSkillLocation, type SkillLocation } from '../prompts/skillPrompts.js'; import type { CliOptions } from '../types.js'; import { type DefaultActionRunnerResult, runDefaultAction } from './defaultAction.js'; -import { promptSkillLocation, type SkillLocation } from './skillPrompts.js'; export const runRemoteAction = async ( repoUrl: string, diff --git a/src/cli/actions/skillPrompts.ts b/src/cli/prompts/skillPrompts.ts similarity index 100% rename from src/cli/actions/skillPrompts.ts rename to src/cli/prompts/skillPrompts.ts From 84c14cc0287e164d12966953bda987fe6af2653c Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 9 Dec 2025 00:22:54 +0900 Subject: [PATCH 18/30] feat(skill): Enhance tech-stack detection and improve skill output - Show all dependencies without truncation (removed 20/10 limits) - Add runtime version detection (.node-version, .nvmrc, .tool-versions, etc.) - Add configuration files section to tech-stack.md - Move statistics section from SKILL.md to summary.md - Update summary.md to use multi-file format language - Increase largest files display from 5 to 10 - Improve generate_skill MCP tool description with skill types and paths --- .gitignore | 3 + src/core/output/outputGenerate.ts | 3 +- .../output/skill/skillSectionGenerators.ts | 30 ++- src/core/output/skill/skillStatistics.ts | 4 +- src/core/output/skill/skillStyle.ts | 10 +- src/core/output/skill/skillTechStack.ts | 241 +++++++++++++++++- src/mcp/tools/generateSkillTool.ts | 20 +- .../skill/skillSectionGenerators.test.ts | 19 +- .../core/output/skill/skillStatistics.test.ts | 6 +- tests/core/output/skill/skillStyle.test.ts | 12 +- .../core/output/skill/skillTechStack.test.ts | 140 +++++++++- 11 files changed, 440 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index cc7e3c610..15a735b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ repomix-output.json # repomix runner .repomix/ +# repomix references +.claude/skills/repomix-reference-*/ + # Agent /.mcp.json .agents/local/ diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 7770c54ec..855008e77 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -459,7 +459,7 @@ export const generateSkillReferences = async ( // Generate each section separately const references: SkillReferences = { - summary: generateSummarySection(renderContext), + summary: generateSummarySection(renderContext, statisticsSection), structure: generateStructureSection(renderContext), files: generateFilesSection(renderContext), techStack: techStackMd, @@ -492,7 +492,6 @@ export const generateSkillMdFromReferences = ( totalFiles: referencesResult.totalFiles, totalLines: referencesResult.totalLines, totalTokens, - statisticsSection: referencesResult.statisticsSection, hasTechStack: referencesResult.hasTechStack, }); diff --git a/src/core/output/skill/skillSectionGenerators.ts b/src/core/output/skill/skillSectionGenerators.ts index 11a7d9055..f3735cad9 100644 --- a/src/core/output/skill/skillSectionGenerators.ts +++ b/src/core/output/skill/skillSectionGenerators.ts @@ -5,27 +5,43 @@ import { getLanguageFromFilePath } from '../outputStyleUtils.js'; /** * Generates the summary section for skill output. - * Contains purpose, file format, usage guidelines, and notes. + * Contains purpose, file format, usage guidelines, notes, and project statistics. */ -export const generateSummarySection = (context: RenderContext): string => { +export const generateSummarySection = (context: RenderContext, statisticsSection?: string): string => { const template = Handlebars.compile(`{{{generationHeader}}} -# File Summary +# Summary ## Purpose -{{{summaryPurpose}}} -## File Format -{{{summaryFileFormat}}} +This is a reference codebase organized into multiple files for AI consumption. +It is designed to be easily searchable using grep and other text-based tools. + +## File Structure + +This skill contains the following reference files: + +| File | Contents | +|------|----------| +| \`project-structure.md\` | Directory tree with line counts per file | +| \`files.md\` | All file contents (search with \`## File: \`) | +| \`tech-stack.md\` | Languages, frameworks, and dependencies | +| \`summary.md\` | This file - purpose and format explanation | ## Usage Guidelines + {{{summaryUsageGuidelines}}} ## Notes + {{{summaryNotes}}} + +{{#if statisticsSection}} +{{{statisticsSection}}} +{{/if}} `); - return template(context).trim(); + return template({ ...context, statisticsSection }).trim(); }; /** diff --git a/src/core/output/skill/skillStatistics.ts b/src/core/output/skill/skillStatistics.ts index a9966e847..e3375a6d9 100644 --- a/src/core/output/skill/skillStatistics.ts +++ b/src/core/output/skill/skillStatistics.ts @@ -140,14 +140,14 @@ export const calculateStatistics = ( })) .sort((a, b) => b.fileCount - a.fileCount); - // Get largest files (top 5) + // Get largest files (top 10) const largestFiles = processedFiles .map((file) => ({ path: file.path, lines: fileLineCounts[file.path] || file.content.split('\n').length, })) .sort((a, b) => b.lines - a.lines) - .slice(0, 5); + .slice(0, 10); return { totalFiles: processedFiles.length, diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index da713c81a..ef6e2f239 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -7,13 +7,13 @@ export interface SkillRenderContext { totalFiles: number; totalLines: number; totalTokens: number; - statisticsSection: string; hasTechStack: boolean; } /** * Returns the Handlebars template for SKILL.md. * Following Claude Agent Skills best practices for progressive disclosure. + * This template is generic and does not contain project-specific statistics. */ export const getSkillTemplate = (): string => { return /* md */ `--- @@ -38,13 +38,11 @@ Use this skill when you need to: | File | Contents | |------|----------| | \`references/project-structure.md\` | Directory tree with line counts per file | -| \`references/files.md\` | All file contents (header: \`## File: \`) | +| \`references/files.md\` | All file contents (search with \`## File: \`) | {{#if hasTechStack}} | \`references/tech-stack.md\` | Languages, frameworks, and dependencies | {{/if}} -| \`references/summary.md\` | Purpose and format explanation | - -{{{statisticsSection}}} +| \`references/summary.md\` | Purpose, format explanation, and statistics | ## How to Use @@ -93,7 +91,7 @@ function calculateTotal - Use line counts in \`project-structure.md\` to estimate file complexity - Search \`## File:\` pattern to jump between files -- Check \`summary.md\` for excluded files and format details +- Check \`summary.md\` for excluded files, format details, and file statistics {{#if hasTechStack}} - Check \`tech-stack.md\` for languages, frameworks, and dependencies {{/if}} diff --git a/src/core/output/skill/skillTechStack.ts b/src/core/output/skill/skillTechStack.ts index 955b3d02a..c2ddb92b7 100644 --- a/src/core/output/skill/skillTechStack.ts +++ b/src/core/output/skill/skillTechStack.ts @@ -6,12 +6,19 @@ interface DependencyInfo { isDev?: boolean; } +interface RuntimeVersion { + runtime: string; + version: string; +} + interface TechStackInfo { languages: string[]; frameworks: string[]; dependencies: DependencyInfo[]; devDependencies: DependencyInfo[]; packageManager?: string; + runtimeVersions: RuntimeVersion[]; + configFiles: string[]; } // Dependency file patterns and their parsers @@ -282,6 +289,189 @@ function parseBuildGradle(content: string): Partial { return { dependencies, frameworks: [...new Set(frameworks)] }; } +// Version manager files and their parsers +const VERSION_FILES: Record RuntimeVersion[]> = { + '.node-version': parseNodeVersion, + '.nvmrc': parseNodeVersion, + '.ruby-version': parseRubyVersion, + '.python-version': parsePythonVersion, + '.go-version': parseGoVersion, + '.java-version': parseJavaVersion, + '.tool-versions': parseToolVersions, +}; + +function parseNodeVersion(content: string): RuntimeVersion[] { + const version = content.trim(); + if (version) { + return [{ runtime: 'Node.js', version }]; + } + return []; +} + +function parseRubyVersion(content: string): RuntimeVersion[] { + const version = content.trim(); + if (version) { + return [{ runtime: 'Ruby', version }]; + } + return []; +} + +function parsePythonVersion(content: string): RuntimeVersion[] { + const version = content.trim(); + if (version) { + return [{ runtime: 'Python', version }]; + } + return []; +} + +function parseGoVersion(content: string): RuntimeVersion[] { + const version = content.trim(); + if (version) { + return [{ runtime: 'Go', version }]; + } + return []; +} + +function parseJavaVersion(content: string): RuntimeVersion[] { + const version = content.trim(); + if (version) { + return [{ runtime: 'Java', version }]; + } + return []; +} + +// Configuration files to detect at root level +const CONFIG_FILE_PATTERNS: string[] = [ + // Package managers and dependencies + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'requirements.txt', + 'pyproject.toml', + 'Pipfile', + 'Pipfile.lock', + 'poetry.lock', + 'go.mod', + 'go.sum', + 'Cargo.toml', + 'Cargo.lock', + 'composer.json', + 'composer.lock', + 'Gemfile', + 'Gemfile.lock', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', + + // TypeScript/JavaScript config + 'tsconfig.json', + 'jsconfig.json', + + // Build tools + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mjs', + 'vitest.config.ts', + 'vitest.config.js', + 'vitest.config.mjs', + 'webpack.config.js', + 'webpack.config.ts', + 'rollup.config.js', + 'rollup.config.ts', + 'esbuild.config.js', + 'turbo.json', + + // Linters and formatters + 'biome.json', + 'biome.jsonc', + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.json', + '.eslintrc.yaml', + '.eslintrc.yml', + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.cjs', + '.prettierrc', + '.prettierrc.js', + '.prettierrc.json', + '.prettierrc.yaml', + '.prettierrc.yml', + 'prettier.config.js', + '.stylelintrc', + '.stylelintrc.json', + + // Version managers + '.node-version', + '.nvmrc', + '.ruby-version', + '.python-version', + '.go-version', + '.java-version', + '.tool-versions', + + // Docker + 'Dockerfile', + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', + + // CI/CD + '.github', + '.gitlab-ci.yml', + 'Jenkinsfile', + '.circleci', + '.travis.yml', + + // Editor config + '.editorconfig', + + // Git + '.gitignore', + '.gitattributes', +]; + +function parseToolVersions(content: string): RuntimeVersion[] { + const versions: RuntimeVersion[] = []; + const runtimeNameMap: Record = { + nodejs: 'Node.js', + node: 'Node.js', + ruby: 'Ruby', + python: 'Python', + golang: 'Go', + go: 'Go', + java: 'Java', + rust: 'Rust', + elixir: 'Elixir', + erlang: 'Erlang', + php: 'PHP', + perl: 'Perl', + deno: 'Deno', + bun: 'Bun', + }; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const parts = trimmed.split(/\s+/); + if (parts.length >= 2) { + const tool = parts[0].toLowerCase(); + const version = parts[1]; + const runtime = runtimeNameMap[tool] || tool; + versions.push({ runtime, version }); + } + } + + return versions; +} + /** * Detects tech stack from processed files. * Only checks root-level dependency files. @@ -292,6 +482,8 @@ export const detectTechStack = (processedFiles: ProcessedFile[]): TechStackInfo frameworks: [], dependencies: [], devDependencies: [], + runtimeVersions: [], + configFiles: [], }; let foundAny = false; @@ -305,6 +497,7 @@ export const detectTechStack = (processedFiles: ProcessedFile[]): TechStackInfo if (dirDepth > 0) continue; } + // Check dependency files const config = DEPENDENCY_FILES[fileName]; if (config) { foundAny = true; @@ -324,6 +517,20 @@ export const detectTechStack = (processedFiles: ProcessedFile[]): TechStackInfo result.packageManager = parsed.packageManager; } } + + // Check version manager files + const versionParser = VERSION_FILES[fileName]; + if (versionParser) { + foundAny = true; + const versions = versionParser(file.content); + result.runtimeVersions.push(...versions); + } + + // Check configuration files + if (CONFIG_FILE_PATTERNS.includes(fileName)) { + foundAny = true; + result.configFiles.push(fileName); + } } if (!foundAny) { @@ -363,6 +570,16 @@ export const generateTechStackMd = (techStack: TechStackInfo): string => { lines.push(''); } + // Runtime Versions + if (techStack.runtimeVersions.length > 0) { + lines.push('## Runtime Versions'); + lines.push(''); + for (const rv of techStack.runtimeVersions) { + lines.push(`- ${rv.runtime}: ${rv.version}`); + } + lines.push(''); + } + // Package Manager if (techStack.packageManager) { lines.push('## Package Manager'); @@ -371,32 +588,34 @@ export const generateTechStackMd = (techStack: TechStackInfo): string => { lines.push(''); } - // Dependencies (limit to top 20 for readability) + // Dependencies if (techStack.dependencies.length > 0) { lines.push('## Dependencies'); lines.push(''); - const deps = techStack.dependencies.slice(0, 20); - for (const dep of deps) { + for (const dep of techStack.dependencies) { const version = dep.version ? ` (${dep.version})` : ''; lines.push(`- ${dep.name}${version}`); } - if (techStack.dependencies.length > 20) { - lines.push(`- ... and ${techStack.dependencies.length - 20} more`); - } lines.push(''); } - // Dev Dependencies (limit to top 10) + // Dev Dependencies if (techStack.devDependencies.length > 0) { lines.push('## Dev Dependencies'); lines.push(''); - const devDeps = techStack.devDependencies.slice(0, 10); - for (const dep of devDeps) { + for (const dep of techStack.devDependencies) { const version = dep.version ? ` (${dep.version})` : ''; lines.push(`- ${dep.name}${version}`); } - if (techStack.devDependencies.length > 10) { - lines.push(`- ... and ${techStack.devDependencies.length - 10} more`); + lines.push(''); + } + + // Configuration Files + if (techStack.configFiles.length > 0) { + lines.push('## Configuration Files'); + lines.push(''); + for (const file of techStack.configFiles) { + lines.push(`- ${file}`); } lines.push(''); } diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index 70771065b..f85e18156 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -47,8 +47,24 @@ export const registerGenerateSkillTool = (mcpServer: McpServer) => { 'generate_skill', { title: 'Generate Claude Agent Skill', - description: - 'Generate a Claude Agent Skill from a local code directory. Creates a skill package at .claude/skills// containing SKILL.md (entry point with metadata) and references/ folder with summary.md, structure.md, files.md, and optionally git-diffs.md and git-logs.md. This skill can be used by Claude to understand and reference the codebase.', + description: `Generate a Claude Agent Skill from a local code directory. Creates a skill package containing SKILL.md (entry point with metadata) and references/ folder with summary.md, project-structure.md, files.md, and optionally tech-stack.md. + +Skill Types: +- Project Skills: Created in /.claude/skills// - shared with the team via version control +- Personal Skills: Created in ~/.claude/skills// - private to your machine + +Output Structure: + .claude/skills// + ├── SKILL.md # Entry point with usage guide + └── references/ + ├── summary.md # Purpose, format, and statistics + ├── project-structure.md # Directory tree with line counts + ├── files.md # All file contents + └── tech-stack.md # Languages, frameworks, dependencies (if detected) + +Example Paths: +- Project: /path/to/project/.claude/skills/repomix-reference-myproject/ +- Personal: ~/.claude/skills/repomix-reference-myproject/`, inputSchema: generateSkillInputSchema, outputSchema: generateSkillOutputSchema, annotations: { diff --git a/tests/core/output/skill/skillSectionGenerators.test.ts b/tests/core/output/skill/skillSectionGenerators.test.ts index b2f17c217..d2b9ac76b 100644 --- a/tests/core/output/skill/skillSectionGenerators.test.ts +++ b/tests/core/output/skill/skillSectionGenerators.test.ts @@ -46,13 +46,26 @@ describe('skillSectionGenerators', () => { const result = generateSummarySection(context); expect(result).toContain('Generated by Repomix'); - expect(result).toContain('# File Summary'); + expect(result).toContain('# Summary'); expect(result).toContain('## Purpose'); - expect(result).toContain('This file contains a packed representation'); - expect(result).toContain('## File Format'); + expect(result).toContain('reference codebase organized into multiple files'); + expect(result).toContain('## File Structure'); + expect(result).toContain('project-structure.md'); + expect(result).toContain('files.md'); + expect(result).toContain('tech-stack.md'); + expect(result).toContain('summary.md'); expect(result).toContain('## Usage Guidelines'); expect(result).toContain('## Notes'); }); + + test('should include statistics section when provided', () => { + const context = createMockContext(); + const statisticsSection = '## Statistics\n\n10 files | 500 lines'; + const result = generateSummarySection(context, statisticsSection); + + expect(result).toContain('## Statistics'); + expect(result).toContain('10 files | 500 lines'); + }); }); describe('generateStructureSection', () => { diff --git a/tests/core/output/skill/skillStatistics.test.ts b/tests/core/output/skill/skillStatistics.test.ts index e5baa7bd9..0a4553502 100644 --- a/tests/core/output/skill/skillStatistics.test.ts +++ b/tests/core/output/skill/skillStatistics.test.ts @@ -62,8 +62,8 @@ describe('skillStatistics', () => { expect(result.largestFiles[2].path).toBe('small.ts'); }); - test('should limit largest files to 5', () => { - const files: ProcessedFile[] = Array.from({ length: 10 }, (_, i) => ({ + test('should limit largest files to 10', () => { + const files: ProcessedFile[] = Array.from({ length: 15 }, (_, i) => ({ path: `file${i}.ts`, content: 'a'.repeat(i + 1), })); @@ -72,7 +72,7 @@ describe('skillStatistics', () => { const result = calculateStatistics(files, lineCounts); - expect(result.largestFiles.length).toBe(5); + expect(result.largestFiles.length).toBe(10); }); test('should sort file types by file count', () => { diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index daf332623..f69160d4d 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -49,7 +49,6 @@ describe('skillStyle', () => { totalFiles: 1, totalLines: 100, totalTokens: 100, - statisticsSection: '## Statistics\n\n1 files | 100 lines', hasTechStack: false, ...overrides, }); @@ -62,7 +61,6 @@ describe('skillStyle', () => { totalFiles: 42, totalLines: 1000, totalTokens: 12345, - statisticsSection: '## Statistics\n\n42 files | 1,000 lines', }); const result = generateSkillMd(context); @@ -106,13 +104,9 @@ describe('skillStyle', () => { expect(result).not.toContain('tech-stack.md'); }); - test('should include statistics section', () => { - const result = generateSkillMd( - createTestContext({ - statisticsSection: '## Statistics\n\n10 files | 500 lines', - }), - ); - expect(result).toContain('## Statistics'); + test('should not include statistics section (moved to summary.md)', () => { + const result = generateSkillMd(createTestContext()); + expect(result).not.toContain('## Statistics'); }); test('should include total lines in header', () => { diff --git a/tests/core/output/skill/skillTechStack.test.ts b/tests/core/output/skill/skillTechStack.test.ts index 913585530..e9956344e 100644 --- a/tests/core/output/skill/skillTechStack.test.ts +++ b/tests/core/output/skill/skillTechStack.test.ts @@ -145,6 +145,8 @@ tokio = { version = "1.0", features = ["full"] }`, ], devDependencies: [{ name: 'typescript', version: '^5.0.0' }], packageManager: 'npm', + runtimeVersions: [{ runtime: 'Node.js', version: '22.0.0' }], + configFiles: ['package.json', 'tsconfig.json'], }; const result = generateTechStackMd(techStack); @@ -155,26 +157,34 @@ tokio = { version = "1.0", features = ["full"] }`, expect(result).toContain('## Frameworks'); expect(result).toContain('- React'); expect(result).toContain('- TypeScript'); + expect(result).toContain('## Runtime Versions'); + expect(result).toContain('- Node.js: 22.0.0'); expect(result).toContain('## Package Manager'); expect(result).toContain('- npm'); expect(result).toContain('## Dependencies'); expect(result).toContain('- react (^18.2.0)'); expect(result).toContain('## Dev Dependencies'); expect(result).toContain('- typescript (^5.0.0)'); + expect(result).toContain('## Configuration Files'); + expect(result).toContain('- package.json'); + expect(result).toContain('- tsconfig.json'); }); - test('should limit dependencies to 20', () => { + test('should show all dependencies without truncation', () => { const techStack = { languages: ['Node.js'], frameworks: [], dependencies: Array.from({ length: 25 }, (_, i) => ({ name: `dep-${i}`, version: '1.0.0' })), devDependencies: [], + runtimeVersions: [], + configFiles: [], }; const result = generateTechStackMd(techStack); - expect(result).toContain('... and 5 more'); - expect(result).not.toContain('dep-24'); + expect(result).toContain('- dep-0 (1.0.0)'); + expect(result).toContain('- dep-24 (1.0.0)'); + expect(result).not.toContain('... and'); }); test('should handle empty sections', () => { @@ -183,6 +193,8 @@ tokio = { version = "1.0", features = ["full"] }`, frameworks: [], dependencies: [], devDependencies: [], + runtimeVersions: [], + configFiles: [], }; const result = generateTechStackMd(techStack); @@ -191,6 +203,128 @@ tokio = { version = "1.0", features = ["full"] }`, expect(result).toContain('## Languages'); expect(result).not.toContain('## Frameworks'); expect(result).not.toContain('## Dependencies'); + expect(result).not.toContain('## Configuration Files'); + }); + }); + + describe('detectTechStack with version files', () => { + test('should detect Node.js version from .node-version', () => { + const files: ProcessedFile[] = [{ path: '.node-version', content: '22.0.0\n' }]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.runtimeVersions).toHaveLength(1); + expect(result?.runtimeVersions[0]).toEqual({ runtime: 'Node.js', version: '22.0.0' }); + }); + + test('should detect Node.js version from .nvmrc', () => { + const files: ProcessedFile[] = [{ path: '.nvmrc', content: 'v20.10.0' }]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.runtimeVersions).toHaveLength(1); + expect(result?.runtimeVersions[0]).toEqual({ runtime: 'Node.js', version: 'v20.10.0' }); + }); + + test('should detect multiple runtimes from .tool-versions', () => { + const files: ProcessedFile[] = [ + { + path: '.tool-versions', + content: `nodejs 22.0.0 +python 3.12.0 +ruby 3.3.0 +# this is a comment +golang 1.22.0`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.runtimeVersions).toHaveLength(4); + expect(result?.runtimeVersions).toContainEqual({ runtime: 'Node.js', version: '22.0.0' }); + expect(result?.runtimeVersions).toContainEqual({ runtime: 'Python', version: '3.12.0' }); + expect(result?.runtimeVersions).toContainEqual({ runtime: 'Ruby', version: '3.3.0' }); + expect(result?.runtimeVersions).toContainEqual({ runtime: 'Go', version: '1.22.0' }); + }); + + test('should detect Python version from .python-version', () => { + const files: ProcessedFile[] = [{ path: '.python-version', content: '3.11.5' }]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.runtimeVersions).toHaveLength(1); + expect(result?.runtimeVersions[0]).toEqual({ runtime: 'Python', version: '3.11.5' }); + }); + + test('should combine dependency files and version files', () => { + const files: ProcessedFile[] = [ + { + path: 'package.json', + content: JSON.stringify({ dependencies: { express: '^4.18.0' } }), + }, + { path: '.node-version', content: '22.0.0' }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Node.js'); + expect(result?.dependencies).toHaveLength(1); + expect(result?.runtimeVersions).toHaveLength(1); + expect(result?.runtimeVersions[0]).toEqual({ runtime: 'Node.js', version: '22.0.0' }); + }); + }); + + describe('detectTechStack with configuration files', () => { + test('should detect configuration files at root level', () => { + const files: ProcessedFile[] = [ + { path: 'package.json', content: JSON.stringify({ dependencies: {} }) }, + { path: 'tsconfig.json', content: '{}' }, + { path: 'vitest.config.ts', content: 'export default {}' }, + { path: '.eslintrc.json', content: '{}' }, + { path: 'biome.json', content: '{}' }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.configFiles).toContain('package.json'); + expect(result?.configFiles).toContain('tsconfig.json'); + expect(result?.configFiles).toContain('vitest.config.ts'); + expect(result?.configFiles).toContain('.eslintrc.json'); + expect(result?.configFiles).toContain('biome.json'); + }); + + test('should not detect configuration files in subdirectories', () => { + const files: ProcessedFile[] = [ + { path: 'package.json', content: JSON.stringify({ dependencies: {} }) }, + { path: 'packages/sub/tsconfig.json', content: '{}' }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.configFiles).toContain('package.json'); + expect(result?.configFiles).not.toContain('tsconfig.json'); + }); + + test('should detect docker and CI configuration files', () => { + const files: ProcessedFile[] = [ + { path: 'Dockerfile', content: 'FROM node:22' }, + { path: 'docker-compose.yml', content: 'version: 3' }, + { path: '.gitignore', content: 'node_modules' }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.configFiles).toContain('Dockerfile'); + expect(result?.configFiles).toContain('docker-compose.yml'); + expect(result?.configFiles).toContain('.gitignore'); }); }); }); From 1987d1d886187cfbd94d48934b5da821cc08349e Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Wed, 10 Dec 2025 00:09:42 +0900 Subject: [PATCH 19/30] refactor(skill): Remove unused git diffs/logs section generators Remove generateGitDiffsSection and generateGitLogsSection functions and their tests as they are no longer used in the skill generation flow. These became dead code after removing git diffs/logs from skill output. --- .../output/skill/skillSectionGenerators.ts | 51 ------------ .../skill/skillSectionGenerators.test.ts | 77 ------------------- 2 files changed, 128 deletions(-) diff --git a/src/core/output/skill/skillSectionGenerators.ts b/src/core/output/skill/skillSectionGenerators.ts index f3735cad9..39c6580c6 100644 --- a/src/core/output/skill/skillSectionGenerators.ts +++ b/src/core/output/skill/skillSectionGenerators.ts @@ -96,54 +96,3 @@ export const generateFilesSection = (context: RenderContext): string => { return template(context).trim(); }; - -/** - * Generates the git diffs section for skill output. - * Returns undefined if git diffs are not enabled. - */ -export const generateGitDiffsSection = (context: RenderContext): string | undefined => { - if (!context.gitDiffEnabled) { - return undefined; - } - - const template = Handlebars.compile(`# Git Diffs - -## Working Tree Changes -\`\`\`diff -{{{gitDiffWorkTree}}} -\`\`\` - -## Staged Changes -\`\`\`diff -{{{gitDiffStaged}}} -\`\`\` -`); - - return template(context).trim(); -}; - -/** - * Generates the git logs section for skill output. - * Returns undefined if git logs are not enabled. - */ -export const generateGitLogsSection = (context: RenderContext): string | undefined => { - if (!context.gitLogEnabled || !context.gitLogCommits) { - return undefined; - } - - const template = Handlebars.compile(`# Git Logs - -{{#each gitLogCommits}} -## Commit: {{{this.date}}} -**Message:** {{{this.message}}} - -**Files:** -{{#each this.files}} -- {{{this}}} -{{/each}} - -{{/each}} -`); - - return template(context).trim(); -}; diff --git a/tests/core/output/skill/skillSectionGenerators.test.ts b/tests/core/output/skill/skillSectionGenerators.test.ts index d2b9ac76b..7b9e51dfd 100644 --- a/tests/core/output/skill/skillSectionGenerators.test.ts +++ b/tests/core/output/skill/skillSectionGenerators.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from 'vitest'; import type { RenderContext } from '../../../../src/core/output/outputGeneratorTypes.js'; import { generateFilesSection, - generateGitDiffsSection, - generateGitLogsSection, generateStructureSection, generateSummarySection, } from '../../../../src/core/output/skill/skillSectionGenerators.js'; @@ -123,79 +121,4 @@ describe('skillSectionGenerators', () => { expect(result).toContain('FROM node:18'); }); }); - - describe('generateGitDiffsSection', () => { - test('should generate git diffs section when enabled', () => { - const context = createMockContext({ - gitDiffEnabled: true, - gitDiffWorkTree: '- old line\n+ new line', - gitDiffStaged: '- staged old\n+ staged new', - }); - const result = generateGitDiffsSection(context); - - expect(result).not.toBeUndefined(); - expect(result).toContain('# Git Diffs'); - expect(result).toContain('## Working Tree Changes'); - expect(result).toContain('```diff'); - expect(result).toContain('- old line'); - expect(result).toContain('+ new line'); - expect(result).toContain('## Staged Changes'); - expect(result).toContain('- staged old'); - }); - - test('should return undefined when git diffs are disabled', () => { - const context = createMockContext({ gitDiffEnabled: false }); - const result = generateGitDiffsSection(context); - - expect(result).toBeUndefined(); - }); - }); - - describe('generateGitLogsSection', () => { - test('should generate git logs section when enabled', () => { - const context = createMockContext({ - gitLogEnabled: true, - gitLogCommits: [ - { - date: '2024-01-15', - message: 'feat: add new feature', - files: ['src/index.ts', 'src/feature.ts'], - }, - { - date: '2024-01-14', - message: 'fix: bug fix', - files: ['src/utils.ts'], - }, - ], - }); - const result = generateGitLogsSection(context); - - expect(result).not.toBeUndefined(); - expect(result).toContain('# Git Logs'); - expect(result).toContain('## Commit: 2024-01-15'); - expect(result).toContain('**Message:** feat: add new feature'); - expect(result).toContain('**Files:**'); - expect(result).toContain('- src/index.ts'); - expect(result).toContain('- src/feature.ts'); - expect(result).toContain('## Commit: 2024-01-14'); - expect(result).toContain('fix: bug fix'); - }); - - test('should return undefined when git logs are disabled', () => { - const context = createMockContext({ gitLogEnabled: false }); - const result = generateGitLogsSection(context); - - expect(result).toBeUndefined(); - }); - - test('should return undefined when git log commits are undefined', () => { - const context = createMockContext({ - gitLogEnabled: true, - gitLogCommits: undefined, - }); - const result = generateGitLogsSection(context); - - expect(result).toBeUndefined(); - }); - }); }); From 54a1391e300725832eab67d591cb0dcfe97d0f5c Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Wed, 10 Dec 2025 00:17:19 +0900 Subject: [PATCH 20/30] refactor(cli): Use cliOptions directly for skillName and skillDir Instead of creating separate variables and passing them through the task, directly update cliOptions.skillName and cliOptions.skillDir. This simplifies the code flow and removes redundant fields from DefaultActionTask interface. --- src/cli/actions/defaultAction.ts | 26 +++++++------------ .../actions/workers/defaultActionWorker.ts | 7 +++-- .../defaultAction.tokenCountTree.test.ts | 8 +++--- 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index ce228a1f8..a6757b9f4 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -53,8 +53,6 @@ export const runDefaultAction = async ( logger.trace('Merged config:', config); // Validate skill generation options and prompt for location - let skillName: string | undefined; - let skillDir: string | undefined; if (config.skillGenerate !== undefined) { if (config.output.stdout) { throw new RepomixError( @@ -68,19 +66,15 @@ export const runDefaultAction = async ( } // Resolve skill name: use pre-computed name (from remoteAction) or generate from directory - skillName = - cliOptions.skillName ?? - (typeof config.skillGenerate === 'string' + cliOptions.skillName ??= + typeof config.skillGenerate === 'string' ? config.skillGenerate - : generateDefaultSkillName(directories.map((d) => path.resolve(cwd, d)))); - - // Use pre-computed skillDir if provided (from remoteAction), otherwise prompt - if (cliOptions.skillDir) { - skillDir = cliOptions.skillDir; - } else { - // Prompt for skill location (personal or project) - const promptResult = await promptSkillLocation(skillName, cwd); - skillDir = promptResult.skillDir; + : generateDefaultSkillName(directories.map((d) => path.resolve(cwd, d))); + + // Prompt for skill location if not already set (from remoteAction) + if (!cliOptions.skillDir) { + const promptResult = await promptSkillLocation(cliOptions.skillName, cwd); + cliOptions.skillDir = promptResult.skillDir; } } @@ -119,15 +113,13 @@ export const runDefaultAction = async ( config, cliOptions, stdinFilePaths, - skillName, - skillDir, }; // Run the task in worker (spinner is handled inside worker) const result = (await taskRunner.run(task)) as DefaultActionWorkerResult; // Report results in main process - reportResults(cwd, result.packResult, result.config, { skillDir }); + reportResults(cwd, result.packResult, result.config, cliOptions); return { packResult: result.packResult, diff --git a/src/cli/actions/workers/defaultActionWorker.ts b/src/cli/actions/workers/defaultActionWorker.ts index 12f806457..bc20b88e5 100644 --- a/src/cli/actions/workers/defaultActionWorker.ts +++ b/src/cli/actions/workers/defaultActionWorker.ts @@ -15,8 +15,6 @@ export interface DefaultActionTask { config: RepomixConfigMerged; cliOptions: CliOptions; stdinFilePaths?: string[]; - skillName?: string; - skillDir?: string; } export interface PingTask { @@ -46,7 +44,7 @@ async function defaultActionWorker( } // At this point, task is guaranteed to be DefaultActionTask - const { directories, cwd, config, cliOptions, stdinFilePaths, skillName, skillDir } = task; + const { directories, cwd, config, cliOptions, stdinFilePaths } = task; logger.trace('Worker: Using pre-loaded config:', config); @@ -57,7 +55,8 @@ async function defaultActionWorker( let packResult: PackResult; try { - const packOptions = { ...(skillName && { skillName }), ...(skillDir && { skillDir }) }; + const { skillName, skillDir } = cliOptions; + const packOptions = { skillName, skillDir }; if (stdinFilePaths) { // Handle stdin processing with file paths from main process diff --git a/tests/cli/actions/defaultAction.tokenCountTree.test.ts b/tests/cli/actions/defaultAction.tokenCountTree.test.ts index e7fb150f2..23f6d91d2 100644 --- a/tests/cli/actions/defaultAction.tokenCountTree.test.ts +++ b/tests/cli/actions/defaultAction.tokenCountTree.test.ts @@ -109,7 +109,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: true, }), }), - { skillDir: undefined }, + expect.any(Object), ); }); @@ -126,7 +126,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: false, }), }), - { skillDir: undefined }, + expect.any(Object), ); }); @@ -157,7 +157,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: true, }), }), - { skillDir: undefined }, + expect.any(Object), ); }); @@ -188,7 +188,7 @@ describe('defaultAction with tokenCountTree', () => { tokenCountTree: 50, }), }), - { skillDir: undefined }, + expect.any(Object), ); }); }); From 0fa885cb79a331a672bbed7a11343b63aeeae934 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 00:12:34 +0900 Subject: [PATCH 21/30] refactor(skill): Move skill-related code to core/skill/ directory Reorganize skill generation code for better domain separation: - Move files from core/output/skill/ to core/skill/ (5 files) - Move writeSkillOutput.ts from core/packager/ to core/skill/ - Create packSkill.ts to encapsulate skill generation logic - Simplify packager.ts by delegating skill generation to packSkill() - Add re-exports in outputGenerate.ts for backward compatibility This change improves code organization by: - Separating skill domain from output domain - Reducing packager.ts complexity - Centralizing all skill-related code in one location --- src/cli/actions/defaultAction.ts | 2 +- src/cli/actions/remoteAction.ts | 2 +- src/core/output/outputGenerate.ts | 150 +---------- src/core/packager.ts | 84 ++---- src/core/skill/packSkill.ts | 255 ++++++++++++++++++ .../skill/skillSectionGenerators.ts | 6 +- .../{output => }/skill/skillStatistics.ts | 2 +- src/core/{output => }/skill/skillStyle.ts | 0 src/core/{output => }/skill/skillTechStack.ts | 2 +- src/core/{output => }/skill/skillUtils.ts | 0 .../{packager => skill}/writeSkillOutput.ts | 0 .../skill/skillSectionGenerators.test.ts | 4 +- .../skill/skillStatistics.test.ts | 4 +- .../{output => }/skill/skillStyle.test.ts | 2 +- .../{output => }/skill/skillTechStack.test.ts | 4 +- .../{output => }/skill/skillUtils.test.ts | 2 +- .../writeSkillOutput.test.ts | 2 +- 17 files changed, 309 insertions(+), 212 deletions(-) create mode 100644 src/core/skill/packSkill.ts rename src/core/{output => }/skill/skillSectionGenerators.ts (91%) rename src/core/{output => }/skill/skillStatistics.ts (98%) rename src/core/{output => }/skill/skillStyle.ts (100%) rename src/core/{output => }/skill/skillTechStack.ts (99%) rename src/core/{output => }/skill/skillUtils.ts (100%) rename src/core/{packager => skill}/writeSkillOutput.ts (100%) rename tests/core/{output => }/skill/skillSectionGenerators.test.ts (96%) rename tests/core/{output => }/skill/skillStatistics.test.ts (97%) rename tests/core/{output => }/skill/skillStyle.test.ts (97%) rename tests/core/{output => }/skill/skillTechStack.test.ts (98%) rename tests/core/{output => }/skill/skillUtils.test.ts (98%) rename tests/core/{packager => skill}/writeSkillOutput.test.ts (96%) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index a6757b9f4..772b91f44 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -8,8 +8,8 @@ import { repomixConfigCliSchema, } from '../../config/configSchema.js'; import { readFilePathsFromStdin } from '../../core/file/fileStdin.js'; -import { generateDefaultSkillName } from '../../core/output/skill/skillUtils.js'; import type { PackResult } from '../../core/packager.js'; +import { generateDefaultSkillName } from '../../core/skill/skillUtils.js'; import { RepomixError, rethrowValidationErrorIfZodError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { splitPatterns } from '../../shared/patternUtils.js'; diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index b431f05ff..6cb32ee83 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -7,7 +7,7 @@ import { downloadGitHubArchive, isArchiveDownloadSupported } from '../../core/gi import { getRemoteRefs } from '../../core/git/gitRemoteHandle.js'; import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../core/git/gitRemoteParse.js'; import { isGitInstalled } from '../../core/git/gitRepositoryHandle.js'; -import { generateDefaultSkillNameFromUrl } from '../../core/output/skill/skillUtils.js'; +import { generateDefaultSkillNameFromUrl } from '../../core/skill/skillUtils.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { Spinner } from '../cliSpinner.js'; diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 855008e77..09c862cd6 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -22,15 +22,6 @@ import { import { getMarkdownTemplate } from './outputStyles/markdownStyle.js'; import { getPlainTemplate } from './outputStyles/plainStyle.js'; import { getXmlTemplate } from './outputStyles/xmlStyle.js'; -import { - generateFilesSection, - generateStructureSection, - generateSummarySection, -} from './skill/skillSectionGenerators.js'; -import { calculateStatistics, generateStatisticsSection } from './skill/skillStatistics.js'; -import { generateSkillMd } from './skill/skillStyle.js'; -import { detectTechStack, generateTechStackMd } from './skill/skillTechStack.js'; -import { generateProjectName, generateSkillDescription, validateSkillName } from './skill/skillUtils.js'; const calculateMarkdownDelimiter = (files: ReadonlyArray): string => { const maxBackticks = files @@ -48,7 +39,7 @@ const calculateFileLineCounts = (processedFiles: ProcessedFile[]): Record { +export const createRenderContext = (outputGeneratorContext: OutputGeneratorContext): RenderContext => { return { generationHeader: generateHeader(outputGeneratorContext.config, outputGeneratorContext.generationDate), summaryPurpose: generateSummaryPurpose(outputGeneratorContext.config), @@ -369,134 +360,11 @@ export const buildOutputGeneratorContext = async ( }; }; -/** - * References for skill output - each becomes a separate file - */ -export interface SkillReferences { - summary: string; - structure: string; - files: string; - techStack?: string; -} - -/** - * Result of skill references generation (without SKILL.md) - */ -export interface SkillReferencesResult { - references: SkillReferences; - skillName: string; - projectName: string; - skillDescription: string; - totalFiles: number; - totalLines: number; - statisticsSection: string; - hasTechStack: boolean; -} - -/** - * Result of skill output generation - */ -export interface SkillOutputResult { - skillMd: string; - references: SkillReferences; -} - -/** - * Generates skill reference files (summary, structure, files, tech-stack). - * This is the first step - call this, calculate metrics, then call generateSkillMdFromReferences. - */ -export const generateSkillReferences = async ( - skillName: string, - rootDirs: string[], - config: RepomixConfigMerged, - processedFiles: ProcessedFile[], - allFilePaths: string[], - gitDiffResult: GitDiffResult | undefined = undefined, - gitLogResult: GitLogResult | undefined = undefined, - deps = { - buildOutputGeneratorContext, - sortOutputFiles, - }, -): Promise => { - // Validate and normalize skill name - const normalizedSkillName = validateSkillName(skillName); - - // Generate project name from root directories - const projectName = generateProjectName(rootDirs); - - // Generate skill description - const skillDescription = generateSkillDescription(normalizedSkillName, projectName); - - // Sort processed files by git change count if enabled - const sortedProcessedFiles = await deps.sortOutputFiles(processedFiles, config); - - // Build output generator context with markdown style - const markdownConfig: RepomixConfigMerged = { - ...config, - output: { - ...config.output, - style: 'markdown', - }, - }; - - const outputGeneratorContext = await deps.buildOutputGeneratorContext( - rootDirs, - markdownConfig, - allFilePaths, - sortedProcessedFiles, - gitDiffResult, - gitLogResult, - ); - const renderContext = createRenderContext(outputGeneratorContext); - - // Calculate statistics - const statistics = calculateStatistics(sortedProcessedFiles, renderContext.fileLineCounts); - const statisticsSection = generateStatisticsSection(statistics); - - // Detect tech stack - const techStack = detectTechStack(sortedProcessedFiles); - const techStackMd = techStack ? generateTechStackMd(techStack) : undefined; - - // Generate each section separately - const references: SkillReferences = { - summary: generateSummarySection(renderContext, statisticsSection), - structure: generateStructureSection(renderContext), - files: generateFilesSection(renderContext), - techStack: techStackMd, - }; - - return { - references, - skillName: normalizedSkillName, - projectName, - skillDescription, - totalFiles: sortedProcessedFiles.length, - totalLines: statistics.totalLines, - statisticsSection, - hasTechStack: techStack !== null, - }; -}; - -/** - * Generates SKILL.md content from references result and token count. - * This is the second step - call after calculating metrics. - */ -export const generateSkillMdFromReferences = ( - referencesResult: SkillReferencesResult, - totalTokens: number, -): SkillOutputResult => { - const skillMd = generateSkillMd({ - skillName: referencesResult.skillName, - skillDescription: referencesResult.skillDescription, - projectName: referencesResult.projectName, - totalFiles: referencesResult.totalFiles, - totalLines: referencesResult.totalLines, - totalTokens, - hasTechStack: referencesResult.hasTechStack, - }); - - return { - skillMd, - references: referencesResult.references, - }; -}; +// Re-export skill types and functions from packSkill.ts for backward compatibility +export { + generateSkillMdFromReferences, + generateSkillReferences, + type SkillOutputResult, + type SkillReferences, + type SkillReferencesResult, +} from '../skill/packSkill.js'; diff --git a/src/core/packager.ts b/src/core/packager.ts index 3995e5aa5..ba0d712f7 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -9,13 +9,12 @@ import type { ProcessedFile } from './file/fileTypes.js'; import { getGitDiffs } from './git/gitDiffHandle.js'; import { getGitLogs } from './git/gitLogHandle.js'; import { calculateMetrics } from './metrics/calculateMetrics.js'; -import { generateOutput, generateSkillMdFromReferences, generateSkillReferences } from './output/outputGenerate.js'; -import { generateDefaultSkillName } from './output/skill/skillUtils.js'; +import { generateOutput } from './output/outputGenerate.js'; import { copyToClipboardIfEnabled } from './packager/copyToClipboardIfEnabled.js'; import { writeOutputToDisk } from './packager/writeOutputToDisk.js'; -import { writeSkillOutput } from './packager/writeSkillOutput.js'; import type { SuspiciousFileResult } from './security/securityCheck.js'; import { validateFileSafety } from './security/validateFileSafety.js'; +import { packSkill } from './skill/packSkill.js'; export interface PackResult { totalFiles: number; @@ -38,17 +37,14 @@ const defaultDeps = { collectFiles, processFiles, generateOutput, - generateSkillReferences, - generateSkillMdFromReferences, - generateDefaultSkillName, validateFileSafety, writeOutputToDisk, - writeSkillOutput, copyToClipboardIfEnabled, calculateMetrics, sortPaths, getGitDiffs, getGitLogs, + packSkill, }; export interface PackOptions { @@ -130,59 +126,37 @@ export const pack = async ( progressCallback('Generating output...'); - let output: string; - // Check if skill generation is requested if (config.skillGenerate !== undefined && options.skillDir) { - // Use pre-computed skill name or generate from directories - const skillName = - options.skillName ?? - (typeof config.skillGenerate === 'string' ? config.skillGenerate : deps.generateDefaultSkillName(rootDirs)); - - // Step 1: Generate skill references (summary, structure, files, git-diffs, git-logs) - const skillReferencesResult = await withMemoryLogging('Generate Skill References', () => - deps.generateSkillReferences( - skillName, - rootDirs, - config, - processedFiles, - allFilePaths, - gitDiffResult, - gitLogResult, - ), - ); - - // Step 2: Calculate metrics from files section to get accurate token count - const skillMetrics = await withMemoryLogging('Calculate Skill Metrics', () => - deps.calculateMetrics( - processedFiles, - skillReferencesResult.references.files, - progressCallback, - config, - gitDiffResult, - gitLogResult, - ), - ); - - // Step 3: Generate SKILL.md with accurate token count - const skillOutput = deps.generateSkillMdFromReferences(skillReferencesResult, skillMetrics.totalTokens); - - progressCallback('Writing skill output...'); - const skillDir = options.skillDir; - await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(skillOutput, skillDir)); + const result = await deps.packSkill({ + rootDirs, + config, + options, + processedFiles, + allFilePaths, + gitDiffResult, + gitLogResult, + suspiciousFilesResults, + suspiciousGitDiffResults, + suspiciousGitLogResults, + safeFilePaths, + skippedFiles: allSkippedFiles, + progressCallback, + }); + + logMemoryUsage('Pack - End'); + return result; + } - // Use files section for final metrics (most representative of content size) - output = skillOutput.references.files; - } else { - output = await withMemoryLogging('Generate Output', () => - deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), - ); + // Normal output generation + const output = await withMemoryLogging('Generate Output', () => + deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult), + ); - progressCallback('Writing output file...'); - await withMemoryLogging('Write Output', () => deps.writeOutputToDisk(output, config)); + progressCallback('Writing output file...'); + await withMemoryLogging('Write Output', () => deps.writeOutputToDisk(output, config)); - await deps.copyToClipboardIfEnabled(output, progressCallback, config); - } + await deps.copyToClipboardIfEnabled(output, progressCallback, config); const metrics = await withMemoryLogging('Calculate Metrics', () => deps.calculateMetrics(processedFiles, output, progressCallback, config, gitDiffResult, gitLogResult), diff --git a/src/core/skill/packSkill.ts b/src/core/skill/packSkill.ts new file mode 100644 index 000000000..cd513b1e3 --- /dev/null +++ b/src/core/skill/packSkill.ts @@ -0,0 +1,255 @@ +import type { RepomixConfigMerged } from '../../config/configSchema.js'; +import { withMemoryLogging } from '../../shared/memoryUtils.js'; +import type { RepomixProgressCallback } from '../../shared/types.js'; +import type { SkippedFileInfo } from '../file/fileCollect.js'; +import type { ProcessedFile } from '../file/fileTypes.js'; +import type { GitDiffResult } from '../git/gitDiffHandle.js'; +import type { GitLogResult } from '../git/gitLogHandle.js'; +import { calculateMetrics } from '../metrics/calculateMetrics.js'; +import { buildOutputGeneratorContext, createRenderContext } from '../output/outputGenerate.js'; +import { sortOutputFiles } from '../output/outputSort.js'; +import type { PackOptions, PackResult } from '../packager.js'; +import type { SuspiciousFileResult } from '../security/securityCheck.js'; +import { generateFilesSection, generateStructureSection, generateSummarySection } from './skillSectionGenerators.js'; +import { calculateStatistics, generateStatisticsSection } from './skillStatistics.js'; +import { generateSkillMd } from './skillStyle.js'; +import { detectTechStack, generateTechStackMd } from './skillTechStack.js'; +import { + generateDefaultSkillName, + generateProjectName, + generateSkillDescription, + validateSkillName, +} from './skillUtils.js'; +import { writeSkillOutput } from './writeSkillOutput.js'; + +/** + * References for skill output - each becomes a separate file + */ +export interface SkillReferences { + summary: string; + structure: string; + files: string; + techStack?: string; +} + +/** + * Result of skill references generation (without SKILL.md) + */ +export interface SkillReferencesResult { + references: SkillReferences; + skillName: string; + projectName: string; + skillDescription: string; + totalFiles: number; + totalLines: number; + statisticsSection: string; + hasTechStack: boolean; +} + +/** + * Result of skill output generation + */ +export interface SkillOutputResult { + skillMd: string; + references: SkillReferences; +} + +const defaultDeps = { + buildOutputGeneratorContext, + sortOutputFiles, + calculateMetrics, + writeSkillOutput, + generateDefaultSkillName, +}; + +/** + * Generates skill reference files (summary, structure, files, tech-stack). + * This is the first step - call this, calculate metrics, then call generateSkillMdFromReferences. + */ +export const generateSkillReferences = async ( + skillName: string, + rootDirs: string[], + config: RepomixConfigMerged, + processedFiles: ProcessedFile[], + allFilePaths: string[], + gitDiffResult: GitDiffResult | undefined = undefined, + gitLogResult: GitLogResult | undefined = undefined, + deps = { + buildOutputGeneratorContext, + sortOutputFiles, + }, +): Promise => { + // Validate and normalize skill name + const normalizedSkillName = validateSkillName(skillName); + + // Generate project name from root directories + const projectName = generateProjectName(rootDirs); + + // Generate skill description + const skillDescription = generateSkillDescription(normalizedSkillName, projectName); + + // Sort processed files by git change count if enabled + const sortedProcessedFiles = await deps.sortOutputFiles(processedFiles, config); + + // Build output generator context with markdown style + const markdownConfig: RepomixConfigMerged = { + ...config, + output: { + ...config.output, + style: 'markdown', + }, + }; + + const outputGeneratorContext = await deps.buildOutputGeneratorContext( + rootDirs, + markdownConfig, + allFilePaths, + sortedProcessedFiles, + gitDiffResult, + gitLogResult, + ); + const renderContext = createRenderContext(outputGeneratorContext); + + // Calculate statistics + const statistics = calculateStatistics(sortedProcessedFiles, renderContext.fileLineCounts); + const statisticsSection = generateStatisticsSection(statistics); + + // Detect tech stack + const techStack = detectTechStack(sortedProcessedFiles); + const techStackMd = techStack ? generateTechStackMd(techStack) : undefined; + + // Generate each section separately + const references: SkillReferences = { + summary: generateSummarySection(renderContext, statisticsSection), + structure: generateStructureSection(renderContext), + files: generateFilesSection(renderContext), + techStack: techStackMd, + }; + + return { + references, + skillName: normalizedSkillName, + projectName, + skillDescription, + totalFiles: sortedProcessedFiles.length, + totalLines: statistics.totalLines, + statisticsSection, + hasTechStack: techStack !== null, + }; +}; + +/** + * Generates SKILL.md content from references result and token count. + * This is the second step - call after calculating metrics. + */ +export const generateSkillMdFromReferences = ( + referencesResult: SkillReferencesResult, + totalTokens: number, +): SkillOutputResult => { + const skillMd = generateSkillMd({ + skillName: referencesResult.skillName, + skillDescription: referencesResult.skillDescription, + projectName: referencesResult.projectName, + totalFiles: referencesResult.totalFiles, + totalLines: referencesResult.totalLines, + totalTokens, + hasTechStack: referencesResult.hasTechStack, + }); + + return { + skillMd, + references: referencesResult.references, + }; +}; + +export interface PackSkillParams { + rootDirs: string[]; + config: RepomixConfigMerged; + options: PackOptions; + processedFiles: ProcessedFile[]; + allFilePaths: string[]; + gitDiffResult: GitDiffResult | undefined; + gitLogResult: GitLogResult | undefined; + suspiciousFilesResults: SuspiciousFileResult[]; + suspiciousGitDiffResults: SuspiciousFileResult[]; + suspiciousGitLogResults: SuspiciousFileResult[]; + safeFilePaths: string[]; + skippedFiles: SkippedFileInfo[]; + progressCallback: RepomixProgressCallback; +} + +/** + * Generates skill output (SKILL.md and reference files). + * This is called from packager.ts when skill generation is requested. + */ +export const packSkill = async (params: PackSkillParams, deps = defaultDeps): Promise => { + const { + rootDirs, + config, + options, + processedFiles, + allFilePaths, + gitDiffResult, + gitLogResult, + suspiciousFilesResults, + suspiciousGitDiffResults, + suspiciousGitLogResults, + safeFilePaths, + skippedFiles, + progressCallback, + } = params; + + // Use pre-computed skill name or generate from directories + const skillName = + options.skillName ?? + (typeof config.skillGenerate === 'string' ? config.skillGenerate : deps.generateDefaultSkillName(rootDirs)); + + // Step 1: Generate skill references (summary, structure, files, tech-stack) + const skillReferencesResult = await withMemoryLogging('Generate Skill References', () => + generateSkillReferences(skillName, rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult, { + buildOutputGeneratorContext: deps.buildOutputGeneratorContext, + sortOutputFiles: deps.sortOutputFiles, + }), + ); + + // Step 2: Calculate metrics from files section to get accurate token count + const skillMetrics = await withMemoryLogging('Calculate Skill Metrics', () => + deps.calculateMetrics( + processedFiles, + skillReferencesResult.references.files, + progressCallback, + config, + gitDiffResult, + gitLogResult, + ), + ); + + // Step 3: Generate SKILL.md with accurate token count + const skillOutput = generateSkillMdFromReferences(skillReferencesResult, skillMetrics.totalTokens); + + // Validate skillDir (should always be set when packSkill is called) + const { skillDir } = options; + if (!skillDir) { + throw new Error('skillDir is required for skill generation'); + } + + progressCallback('Writing skill output...'); + await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(skillOutput, skillDir)); + + // Use files section for final metrics (most representative of content size) + const output = skillOutput.references.files; + + const metrics = await withMemoryLogging('Calculate Metrics', () => + deps.calculateMetrics(processedFiles, output, progressCallback, config, gitDiffResult, gitLogResult), + ); + + return { + ...metrics, + suspiciousFilesResults, + suspiciousGitDiffResults, + suspiciousGitLogResults, + processedFiles, + safeFilePaths, + skippedFiles, + }; +}; diff --git a/src/core/output/skill/skillSectionGenerators.ts b/src/core/skill/skillSectionGenerators.ts similarity index 91% rename from src/core/output/skill/skillSectionGenerators.ts rename to src/core/skill/skillSectionGenerators.ts index 39c6580c6..4b4f009c0 100644 --- a/src/core/output/skill/skillSectionGenerators.ts +++ b/src/core/skill/skillSectionGenerators.ts @@ -1,7 +1,7 @@ import Handlebars from 'handlebars'; -import { generateTreeStringWithLineCounts } from '../../file/fileTreeGenerate.js'; -import type { RenderContext } from '../outputGeneratorTypes.js'; -import { getLanguageFromFilePath } from '../outputStyleUtils.js'; +import { generateTreeStringWithLineCounts } from '../file/fileTreeGenerate.js'; +import type { RenderContext } from '../output/outputGeneratorTypes.js'; +import { getLanguageFromFilePath } from '../output/outputStyleUtils.js'; /** * Generates the summary section for skill output. diff --git a/src/core/output/skill/skillStatistics.ts b/src/core/skill/skillStatistics.ts similarity index 98% rename from src/core/output/skill/skillStatistics.ts rename to src/core/skill/skillStatistics.ts index e3375a6d9..f36033ebc 100644 --- a/src/core/output/skill/skillStatistics.ts +++ b/src/core/skill/skillStatistics.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type { ProcessedFile } from '../../file/fileTypes.js'; +import type { ProcessedFile } from '../file/fileTypes.js'; interface FileTypeStats { extension: string; diff --git a/src/core/output/skill/skillStyle.ts b/src/core/skill/skillStyle.ts similarity index 100% rename from src/core/output/skill/skillStyle.ts rename to src/core/skill/skillStyle.ts diff --git a/src/core/output/skill/skillTechStack.ts b/src/core/skill/skillTechStack.ts similarity index 99% rename from src/core/output/skill/skillTechStack.ts rename to src/core/skill/skillTechStack.ts index c2ddb92b7..b13c422a9 100644 --- a/src/core/output/skill/skillTechStack.ts +++ b/src/core/skill/skillTechStack.ts @@ -1,4 +1,4 @@ -import type { ProcessedFile } from '../../file/fileTypes.js'; +import type { ProcessedFile } from '../file/fileTypes.js'; interface DependencyInfo { name: string; diff --git a/src/core/output/skill/skillUtils.ts b/src/core/skill/skillUtils.ts similarity index 100% rename from src/core/output/skill/skillUtils.ts rename to src/core/skill/skillUtils.ts diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/skill/writeSkillOutput.ts similarity index 100% rename from src/core/packager/writeSkillOutput.ts rename to src/core/skill/writeSkillOutput.ts diff --git a/tests/core/output/skill/skillSectionGenerators.test.ts b/tests/core/skill/skillSectionGenerators.test.ts similarity index 96% rename from tests/core/output/skill/skillSectionGenerators.test.ts rename to tests/core/skill/skillSectionGenerators.test.ts index 7b9e51dfd..ac3938a3a 100644 --- a/tests/core/output/skill/skillSectionGenerators.test.ts +++ b/tests/core/skill/skillSectionGenerators.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest'; -import type { RenderContext } from '../../../../src/core/output/outputGeneratorTypes.js'; +import type { RenderContext } from '../../../src/core/output/outputGeneratorTypes.js'; import { generateFilesSection, generateStructureSection, generateSummarySection, -} from '../../../../src/core/output/skill/skillSectionGenerators.js'; +} from '../../../src/core/skill/skillSectionGenerators.js'; const createMockContext = (overrides: Partial = {}): RenderContext => ({ generationHeader: 'Generated by Repomix', diff --git a/tests/core/output/skill/skillStatistics.test.ts b/tests/core/skill/skillStatistics.test.ts similarity index 97% rename from tests/core/output/skill/skillStatistics.test.ts rename to tests/core/skill/skillStatistics.test.ts index 0a4553502..edfd2c7e8 100644 --- a/tests/core/output/skill/skillStatistics.test.ts +++ b/tests/core/skill/skillStatistics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; -import { calculateStatistics, generateStatisticsSection } from '../../../../src/core/output/skill/skillStatistics.js'; +import type { ProcessedFile } from '../../../src/core/file/fileTypes.js'; +import { calculateStatistics, generateStatisticsSection } from '../../../src/core/skill/skillStatistics.js'; describe('skillStatistics', () => { describe('calculateStatistics', () => { diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/skill/skillStyle.test.ts similarity index 97% rename from tests/core/output/skill/skillStyle.test.ts rename to tests/core/skill/skillStyle.test.ts index f69160d4d..a28438e7f 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/skill/skillStyle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { generateSkillMd, getSkillTemplate } from '../../../../src/core/output/skill/skillStyle.js'; +import { generateSkillMd, getSkillTemplate } from '../../../src/core/skill/skillStyle.js'; describe('skillStyle', () => { describe('getSkillTemplate', () => { diff --git a/tests/core/output/skill/skillTechStack.test.ts b/tests/core/skill/skillTechStack.test.ts similarity index 98% rename from tests/core/output/skill/skillTechStack.test.ts rename to tests/core/skill/skillTechStack.test.ts index e9956344e..c613923e2 100644 --- a/tests/core/output/skill/skillTechStack.test.ts +++ b/tests/core/skill/skillTechStack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; -import { detectTechStack, generateTechStackMd } from '../../../../src/core/output/skill/skillTechStack.js'; +import type { ProcessedFile } from '../../../src/core/file/fileTypes.js'; +import { detectTechStack, generateTechStackMd } from '../../../src/core/skill/skillTechStack.js'; describe('skillTechStack', () => { describe('detectTechStack', () => { diff --git a/tests/core/output/skill/skillUtils.test.ts b/tests/core/skill/skillUtils.test.ts similarity index 98% rename from tests/core/output/skill/skillUtils.test.ts rename to tests/core/skill/skillUtils.test.ts index 121c4a303..847a0583f 100644 --- a/tests/core/output/skill/skillUtils.test.ts +++ b/tests/core/skill/skillUtils.test.ts @@ -4,7 +4,7 @@ import { generateSkillDescription, toKebabCase, validateSkillName, -} from '../../../../src/core/output/skill/skillUtils.js'; +} from '../../../src/core/skill/skillUtils.js'; describe('skillUtils', () => { describe('toKebabCase', () => { diff --git a/tests/core/packager/writeSkillOutput.test.ts b/tests/core/skill/writeSkillOutput.test.ts similarity index 96% rename from tests/core/packager/writeSkillOutput.test.ts rename to tests/core/skill/writeSkillOutput.test.ts index bc44379f5..e5e6e3864 100644 --- a/tests/core/packager/writeSkillOutput.test.ts +++ b/tests/core/skill/writeSkillOutput.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { describe, expect, test, vi } from 'vitest'; -import { writeSkillOutput } from '../../../src/core/packager/writeSkillOutput.js'; +import { writeSkillOutput } from '../../../src/core/skill/writeSkillOutput.js'; describe('writeSkillOutput', () => { test('should create skill directory structure and write files', async () => { From e52a4307c4d28572672820affec4701229928332 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 00:21:07 +0900 Subject: [PATCH 22/30] refactor(skill): Remove unnecessary re-exports from outputGenerate.ts Skill feature is new in this branch, so backward compatibility re-exports are not needed. Also fix writeSkillOutput.ts to import SkillOutputResult from packSkill.ts directly. --- src/core/output/outputGenerate.ts | 9 --------- src/core/skill/writeSkillOutput.ts | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 09c862cd6..66f3799b0 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -359,12 +359,3 @@ export const buildOutputGeneratorContext = async ( gitLogResult, }; }; - -// Re-export skill types and functions from packSkill.ts for backward compatibility -export { - generateSkillMdFromReferences, - generateSkillReferences, - type SkillOutputResult, - type SkillReferences, - type SkillReferencesResult, -} from '../skill/packSkill.js'; diff --git a/src/core/skill/writeSkillOutput.ts b/src/core/skill/writeSkillOutput.ts index 2de687d4b..361ca4c94 100644 --- a/src/core/skill/writeSkillOutput.ts +++ b/src/core/skill/writeSkillOutput.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { RepomixError } from '../../shared/errorHandle.js'; -import type { SkillOutputResult } from '../output/outputGenerate.js'; +import type { SkillOutputResult } from './packSkill.js'; /** * Writes skill output to the filesystem. From 6002e067e1649a0319d1c577ee84ac5ecc900195 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:14:27 +0900 Subject: [PATCH 23/30] fix(skill): Remove duplicate calculateMetrics call and fix MCP tool - Remove redundant second calculateMetrics call in packSkill.ts (skillMetrics already contains the same result) - Fix MCP generateSkillTool to pre-compute skillDir explicitly to avoid interactive prompts in non-interactive MCP context --- src/core/skill/packSkill.ts | 9 +-------- src/mcp/tools/generateSkillTool.ts | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/core/skill/packSkill.ts b/src/core/skill/packSkill.ts index cd513b1e3..4388c49bf 100644 --- a/src/core/skill/packSkill.ts +++ b/src/core/skill/packSkill.ts @@ -236,15 +236,8 @@ export const packSkill = async (params: PackSkillParams, deps = defaultDeps): Pr progressCallback('Writing skill output...'); await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(skillOutput, skillDir)); - // Use files section for final metrics (most representative of content size) - const output = skillOutput.references.files; - - const metrics = await withMemoryLogging('Calculate Metrics', () => - deps.calculateMetrics(processedFiles, output, progressCallback, config, gitDiffResult, gitLogResult), - ); - return { - ...metrics, + ...skillMetrics, suspiciousFilesResults, suspiciousGitDiffResults, suspiciousGitLogResults, diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index f85e18156..11a1ba31f 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -3,7 +3,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { runCli } from '../../cli/cliRun.js'; +import { getSkillBaseDir } from '../../cli/prompts/skillPrompts.js'; import type { CliOptions } from '../../cli/types.js'; +import { generateDefaultSkillName } from '../../core/skill/skillUtils.js'; import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, convertErrorToJson } from './mcpToolRuntime.js'; const generateSkillInputSchema = z.object({ @@ -76,9 +78,15 @@ Example Paths: }, async ({ directory, skillName, compress, includePatterns, ignorePatterns }): Promise => { try { - // skillGenerate can be a string (explicit name) or true (auto-generate) + // Pre-compute skill name and directory to avoid interactive prompts + // MCP is non-interactive, so we must specify skillDir explicitly + const actualSkillName = skillName ?? generateDefaultSkillName([directory]); + const skillDir = path.join(getSkillBaseDir(directory, 'project'), actualSkillName); + const cliOptions = { - skillGenerate: skillName ?? true, + skillGenerate: actualSkillName, + skillName: actualSkillName, + skillDir, compress, include: includePatterns, ignore: ignorePatterns, @@ -93,18 +101,14 @@ Example Paths: }); } - const { packResult, config } = result; - // Get the actual skill name from config (may be auto-generated) - const actualSkillName = - typeof config.skillGenerate === 'string' ? config.skillGenerate : skillName || 'repomix-reference'; - const skillPath = path.join(directory, '.claude', 'skills', actualSkillName); + const { packResult } = result; return buildMcpToolSuccessResponse({ - skillPath, + skillPath: skillDir, skillName: actualSkillName, totalFiles: packResult.totalFiles, totalTokens: packResult.totalTokens, - description: `Successfully generated Claude Agent Skill at ${skillPath}. The skill contains ${packResult.totalFiles} files with ${packResult.totalTokens.toLocaleString()} tokens.`, + description: `Successfully generated Claude Agent Skill at ${skillDir}. The skill contains ${packResult.totalFiles} files with ${packResult.totalTokens.toLocaleString()} tokens.`, } satisfies z.infer); } catch (error) { return buildMcpToolErrorResponse(convertErrorToJson(error)); From d6d82b6392ef17915784556beaa585a4d6b0ad44 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:20:51 +0900 Subject: [PATCH 24/30] fix(security): Add path traversal protection for skill generation - Add validation in validateSkillName to reject path separators (/, \), null bytes, and dot-only names (., .., ...) - Add absolute path validation in MCP generateSkillTool - Add directory existence check before skill generation in MCP tool - Add existing skill directory check to prevent silent overwrites - Add security tests for path traversal prevention --- src/core/skill/skillUtils.ts | 11 +++++++++++ src/mcp/tools/generateSkillTool.ts | 27 +++++++++++++++++++++++++++ tests/core/skill/skillUtils.test.ts | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/src/core/skill/skillUtils.ts b/src/core/skill/skillUtils.ts index f234cd8c7..62b6a155d 100644 --- a/src/core/skill/skillUtils.ts +++ b/src/core/skill/skillUtils.ts @@ -21,8 +21,19 @@ export const toKebabCase = (str: string): string => { /** * Validates and normalizes a skill name. * Converts to kebab-case and truncates to 64 characters. + * Also rejects path traversal attempts. */ export const validateSkillName = (name: string): string => { + // Reject path separators and null bytes to prevent path traversal + if (name.includes('/') || name.includes('\\') || name.includes('\0')) { + throw new Error('Skill name cannot contain path separators or null bytes'); + } + + // Reject dot-only names (., .., ...) + if (/^\.+$/.test(name)) { + throw new Error('Skill name cannot consist only of dots'); + } + const kebabName = toKebabCase(name); if (kebabName.length === 0) { diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index 11a1ba31f..9c45612cd 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises'; import path from 'node:path'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; @@ -78,11 +79,37 @@ Example Paths: }, async ({ directory, skillName, compress, includePatterns, ignorePatterns }): Promise => { try { + // Validate directory is an absolute path + if (!path.isAbsolute(directory)) { + return buildMcpToolErrorResponse({ + errorMessage: `Directory must be an absolute path: ${directory}`, + }); + } + + // Check if directory exists and is accessible + try { + await fs.access(directory); + } catch { + return buildMcpToolErrorResponse({ + errorMessage: `Directory not accessible: ${directory}`, + }); + } + // Pre-compute skill name and directory to avoid interactive prompts // MCP is non-interactive, so we must specify skillDir explicitly const actualSkillName = skillName ?? generateDefaultSkillName([directory]); const skillDir = path.join(getSkillBaseDir(directory, 'project'), actualSkillName); + // Check if skill directory already exists (MCP cannot prompt for overwrite) + try { + await fs.access(skillDir); + return buildMcpToolErrorResponse({ + errorMessage: `Skill directory already exists: ${skillDir}. Please remove it first or use a different skill name.`, + }); + } catch { + // Directory doesn't exist - this is expected + } + const cliOptions = { skillGenerate: actualSkillName, skillName: actualSkillName, diff --git a/tests/core/skill/skillUtils.test.ts b/tests/core/skill/skillUtils.test.ts index 847a0583f..04632d91b 100644 --- a/tests/core/skill/skillUtils.test.ts +++ b/tests/core/skill/skillUtils.test.ts @@ -67,6 +67,27 @@ describe('skillUtils', () => { test('should handle valid kebab-case names', () => { expect(validateSkillName('my-valid-skill-name')).toBe('my-valid-skill-name'); }); + + // Security tests for path traversal prevention + test('should reject path traversal attempts with forward slashes', () => { + expect(() => validateSkillName('../../../etc/passwd')).toThrow('Skill name cannot contain path separators'); + expect(() => validateSkillName('foo/bar')).toThrow('Skill name cannot contain path separators'); + }); + + test('should reject path traversal attempts with backslashes', () => { + expect(() => validateSkillName('..\\..\\etc\\passwd')).toThrow('Skill name cannot contain path separators'); + expect(() => validateSkillName('foo\\bar')).toThrow('Skill name cannot contain path separators'); + }); + + test('should reject null bytes', () => { + expect(() => validateSkillName('foo\0bar')).toThrow('Skill name cannot contain path separators or null bytes'); + }); + + test('should reject dot-only names', () => { + expect(() => validateSkillName('.')).toThrow('Skill name cannot consist only of dots'); + expect(() => validateSkillName('..')).toThrow('Skill name cannot consist only of dots'); + expect(() => validateSkillName('...')).toThrow('Skill name cannot consist only of dots'); + }); }); describe('generateProjectName', () => { From 6a397a01acc1ae731fcab2a150eabfee92e23b2e Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:30:25 +0900 Subject: [PATCH 25/30] test(skill): Add tests for skill generation functionality Add comprehensive tests for: - packSkill.ts: generateSkillReferences, generateSkillMdFromReferences, packSkill - skillPrompts.ts: getSkillBaseDir, promptSkillLocation - generateSkillTool.ts: MCP tool registration, input validation, error handling --- tests/cli/prompts/skillPrompts.test.ts | 132 ++++++++ tests/core/skill/packSkill.test.ts | 373 ++++++++++++++++++++++ tests/mcp/tools/generateSkillTool.test.ts | 254 +++++++++++++++ 3 files changed, 759 insertions(+) create mode 100644 tests/cli/prompts/skillPrompts.test.ts create mode 100644 tests/core/skill/packSkill.test.ts create mode 100644 tests/mcp/tools/generateSkillTool.test.ts diff --git a/tests/cli/prompts/skillPrompts.test.ts b/tests/cli/prompts/skillPrompts.test.ts new file mode 100644 index 000000000..f5c6cb33c --- /dev/null +++ b/tests/cli/prompts/skillPrompts.test.ts @@ -0,0 +1,132 @@ +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, test, vi } from 'vitest'; +import { getSkillBaseDir, promptSkillLocation } from '../../../src/cli/prompts/skillPrompts.js'; + +// Helper to create mock deps with proper typing +const createMockDeps = (overrides: { + selectValue: unknown; + confirmValue?: unknown; + isCancelFn: (value: unknown) => boolean; + accessRejects: boolean; +}) => ({ + select: vi.fn().mockResolvedValue(overrides.selectValue), + confirm: vi.fn().mockResolvedValue(overrides.confirmValue), + isCancel: overrides.isCancelFn as (value: unknown) => value is symbol, + access: overrides.accessRejects + ? vi.fn().mockRejectedValue(new Error('ENOENT')) + : vi.fn().mockResolvedValue(undefined), +}); + +describe('skillPrompts', () => { + describe('getSkillBaseDir', () => { + test('should return personal skills directory for personal location', () => { + const result = getSkillBaseDir('/test/project', 'personal'); + expect(result).toBe(path.join(os.homedir(), '.claude', 'skills')); + }); + + test('should return project skills directory for project location', () => { + const result = getSkillBaseDir('/test/project', 'project'); + expect(result).toBe(path.join('/test/project', '.claude', 'skills')); + }); + }); + + describe('promptSkillLocation', () => { + test('should return personal location when selected', async () => { + const mockDeps = createMockDeps({ + selectValue: 'personal', + isCancelFn: () => false, + accessRejects: true, + }); + + const result = await promptSkillLocation('test-skill', '/test/project', mockDeps); + + expect(result.location).toBe('personal'); + expect(result.skillDir).toBe(path.join(os.homedir(), '.claude', 'skills', 'test-skill')); + expect(mockDeps.select).toHaveBeenCalledOnce(); + expect(mockDeps.confirm).not.toHaveBeenCalled(); + }); + + test('should return project location when selected', async () => { + const mockDeps = createMockDeps({ + selectValue: 'project', + isCancelFn: () => false, + accessRejects: true, + }); + + const result = await promptSkillLocation('test-skill', '/test/project', mockDeps); + + expect(result.location).toBe('project'); + expect(result.skillDir).toBe(path.join('/test/project', '.claude', 'skills', 'test-skill')); + }); + + test('should prompt for overwrite when directory exists', async () => { + const mockDeps = createMockDeps({ + selectValue: 'personal', + confirmValue: true, + isCancelFn: () => false, + accessRejects: false, // Directory exists + }); + + const result = await promptSkillLocation('test-skill', '/test/project', mockDeps); + + expect(mockDeps.confirm).toHaveBeenCalledOnce(); + expect(result.location).toBe('personal'); + }); + + test('should exit when select is cancelled', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockDeps = createMockDeps({ + selectValue: Symbol('cancel'), + isCancelFn: () => true, + accessRejects: true, + }); + + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); + + mockExit.mockRestore(); + }); + + test('should exit when overwrite is declined', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockDeps = createMockDeps({ + selectValue: 'personal', + confirmValue: false, + isCancelFn: () => false, + accessRejects: false, // Directory exists + }); + + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); + + mockExit.mockRestore(); + }); + + test('should exit when confirm is cancelled', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + let callCount = 0; + const mockDeps = createMockDeps({ + selectValue: 'personal', + confirmValue: Symbol('cancel'), + isCancelFn: () => { + callCount++; + // First call for select returns false, second call for confirm returns true (cancelled) + return callCount > 1; + }, + accessRejects: false, // Directory exists + }); + + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); + + mockExit.mockRestore(); + }); + }); +}); diff --git a/tests/core/skill/packSkill.test.ts b/tests/core/skill/packSkill.test.ts new file mode 100644 index 000000000..397792b58 --- /dev/null +++ b/tests/core/skill/packSkill.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { ProcessedFile } from '../../../src/core/file/fileTypes.js'; +import { + generateSkillMdFromReferences, + generateSkillReferences, + type PackSkillParams, + packSkill, + type SkillReferencesResult, +} from '../../../src/core/skill/packSkill.js'; +import { createMockConfig } from '../../testing/testUtils.js'; + +// Mock processed files +const createMockProcessedFiles = (): ProcessedFile[] => [ + { path: 'src/index.ts', content: 'console.log("hello");' }, + { path: 'src/utils.ts', content: 'export const add = (a, b) => a + b;' }, + { path: 'package.json', content: '{"name": "test", "dependencies": {"react": "^18.0.0"}}' }, +]; + +describe('packSkill', () => { + describe('generateSkillReferences', () => { + test('should generate all reference sections with valid data', async () => { + const mockConfig = createMockConfig(); + const mockFiles = createMockProcessedFiles(); + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: 'src/\n index.ts\n utils.ts', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + }; + + const result = await generateSkillReferences( + 'test-skill', + ['/test/project'], + mockConfig, + mockFiles, + ['src/index.ts', 'src/utils.ts', 'package.json'], + undefined, + undefined, + mockDeps, + ); + + expect(result.skillName).toBe('test-skill'); + expect(result.projectName).toBeTruthy(); + expect(result.skillDescription).toContain('Reference codebase'); + expect(result.totalFiles).toBe(3); + expect(result.references.summary).toBeTruthy(); + expect(result.references.structure).toBeTruthy(); + expect(result.references.files).toBeTruthy(); + }); + + test('should normalize skill name', async () => { + const mockConfig = createMockConfig(); + const mockFiles = createMockProcessedFiles(); + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + }; + + const result = await generateSkillReferences( + 'MyTestSkill', + ['/test/project'], + mockConfig, + mockFiles, + [], + undefined, + undefined, + mockDeps, + ); + + expect(result.skillName).toBe('my-test-skill'); + }); + + test('should detect tech stack when available', async () => { + const mockConfig = createMockConfig(); + const mockFiles: ProcessedFile[] = [{ path: 'package.json', content: '{"dependencies": {"react": "^18.0.0"}}' }]; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + }; + + const result = await generateSkillReferences( + 'test-skill', + ['/test/project'], + mockConfig, + mockFiles, + ['package.json'], + undefined, + undefined, + mockDeps, + ); + + expect(result.hasTechStack).toBe(true); + expect(result.references.techStack).toBeTruthy(); + }); + + test('should handle empty processed files', async () => { + const mockConfig = createMockConfig(); + const mockFiles: ProcessedFile[] = []; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + }; + + const result = await generateSkillReferences( + 'test-skill', + ['/test/project'], + mockConfig, + mockFiles, + [], + undefined, + undefined, + mockDeps, + ); + + expect(result.totalFiles).toBe(0); + expect(result.totalLines).toBe(0); + }); + }); + + describe('generateSkillMdFromReferences', () => { + test('should generate SKILL.md with all metadata', () => { + const referencesResult: SkillReferencesResult = { + references: { + summary: 'Summary content', + structure: 'Structure content', + files: 'Files content', + techStack: 'Tech stack content', + }, + skillName: 'test-skill', + projectName: 'Test Project', + skillDescription: 'Test description', + totalFiles: 10, + totalLines: 500, + statisticsSection: 'Statistics', + hasTechStack: true, + }; + + const result = generateSkillMdFromReferences(referencesResult, 1000); + + expect(result.skillMd).toContain('test-skill'); + expect(result.skillMd).toContain('Test Project'); + expect(result.skillMd).toContain('10 files'); + expect(result.skillMd).toContain('500 lines'); + expect(result.skillMd).toContain('1000 tokens'); + expect(result.references).toBe(referencesResult.references); + }); + + test('should handle hasTechStack false', () => { + const referencesResult: SkillReferencesResult = { + references: { + summary: 'Summary content', + structure: 'Structure content', + files: 'Files content', + }, + skillName: 'test-skill', + projectName: 'Test Project', + skillDescription: 'Test description', + totalFiles: 5, + totalLines: 100, + statisticsSection: 'Statistics', + hasTechStack: false, + }; + + const result = generateSkillMdFromReferences(referencesResult, 500); + + expect(result.skillMd).toContain('test-skill'); + expect(result.references.techStack).toBeUndefined(); + }); + }); + + describe('packSkill', () => { + test('should throw error when skillDir is missing', async () => { + const mockConfig = createMockConfig({ skillGenerate: 'test-skill' }); + const mockFiles = createMockProcessedFiles(); + + const params: PackSkillParams = { + rootDirs: ['/test/project'], + config: mockConfig, + options: { skillName: 'test-skill' }, // skillDir is missing + processedFiles: mockFiles, + allFilePaths: ['src/index.ts'], + gitDiffResult: undefined, + gitLogResult: undefined, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + safeFilePaths: ['src/index.ts'], + skippedFiles: [], + progressCallback: vi.fn(), + }; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + calculateMetrics: vi.fn().mockResolvedValue({ + totalFiles: 1, + totalCharacters: 100, + totalTokens: 50, + }), + writeSkillOutput: vi.fn().mockResolvedValue('/test/skill'), + generateDefaultSkillName: vi.fn().mockReturnValue('test-skill'), + }; + + await expect(packSkill(params, mockDeps)).rejects.toThrow('skillDir is required for skill generation'); + }); + + test('should generate complete skill package', async () => { + const mockConfig = createMockConfig({ skillGenerate: 'test-skill' }); + const mockFiles = createMockProcessedFiles(); + + const params: PackSkillParams = { + rootDirs: ['/test/project'], + config: mockConfig, + options: { skillName: 'test-skill', skillDir: '/test/.claude/skills/test-skill' }, + processedFiles: mockFiles, + allFilePaths: ['src/index.ts', 'src/utils.ts', 'package.json'], + gitDiffResult: undefined, + gitLogResult: undefined, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + safeFilePaths: ['src/index.ts', 'src/utils.ts', 'package.json'], + skippedFiles: [], + progressCallback: vi.fn(), + }; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: 'src/\n index.ts\n utils.ts', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + calculateMetrics: vi.fn().mockResolvedValue({ + totalFiles: 3, + totalCharacters: 500, + totalTokens: 100, + }), + writeSkillOutput: vi.fn().mockResolvedValue('/test/.claude/skills/test-skill'), + generateDefaultSkillName: vi.fn().mockReturnValue('test-skill'), + }; + + const result = await packSkill(params, mockDeps); + + expect(result.totalFiles).toBe(3); + expect(result.totalTokens).toBe(100); + expect(mockDeps.writeSkillOutput).toHaveBeenCalled(); + expect(params.progressCallback).toHaveBeenCalledWith('Writing skill output...'); + }); + + test('should use config.skillGenerate as skill name when string', async () => { + const mockConfig = createMockConfig({ skillGenerate: 'custom-skill-name' }); + const mockFiles = createMockProcessedFiles(); + + const params: PackSkillParams = { + rootDirs: ['/test/project'], + config: mockConfig, + options: { skillDir: '/test/.claude/skills/custom-skill-name' }, // No skillName + processedFiles: mockFiles, + allFilePaths: [], + gitDiffResult: undefined, + gitLogResult: undefined, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + safeFilePaths: [], + skippedFiles: [], + progressCallback: vi.fn(), + }; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + calculateMetrics: vi.fn().mockResolvedValue({ + totalFiles: 0, + totalCharacters: 0, + totalTokens: 0, + }), + writeSkillOutput: vi.fn().mockResolvedValue('/test/.claude/skills/custom-skill-name'), + generateDefaultSkillName: vi.fn().mockReturnValue('default-name'), + }; + + await packSkill(params, mockDeps); + + // generateDefaultSkillName should NOT be called since config.skillGenerate is a string + expect(mockDeps.generateDefaultSkillName).not.toHaveBeenCalled(); + }); + + test('should generate default skill name when skillGenerate is boolean', async () => { + const mockConfig = createMockConfig({ skillGenerate: true }); + const mockFiles = createMockProcessedFiles(); + + const params: PackSkillParams = { + rootDirs: ['/test/project'], + config: mockConfig, + options: { skillDir: '/test/.claude/skills/generated-name' }, // No skillName + processedFiles: mockFiles, + allFilePaths: [], + gitDiffResult: undefined, + gitLogResult: undefined, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + safeFilePaths: [], + skippedFiles: [], + progressCallback: vi.fn(), + }; + + const mockDeps = { + buildOutputGeneratorContext: vi.fn().mockResolvedValue({ + config: mockConfig, + generationDate: new Date().toISOString(), + treeString: '', + processedFiles: mockFiles, + instruction: '', + }), + sortOutputFiles: vi.fn().mockResolvedValue(mockFiles), + calculateMetrics: vi.fn().mockResolvedValue({ + totalFiles: 0, + totalCharacters: 0, + totalTokens: 0, + }), + writeSkillOutput: vi.fn().mockResolvedValue('/test/.claude/skills/generated-name'), + generateDefaultSkillName: vi.fn().mockReturnValue('repomix-reference-project'), + }; + + await packSkill(params, mockDeps); + + // generateDefaultSkillName SHOULD be called since config.skillGenerate is boolean + expect(mockDeps.generateDefaultSkillName).toHaveBeenCalledWith(['/test/project']); + }); + }); +}); diff --git a/tests/mcp/tools/generateSkillTool.test.ts b/tests/mcp/tools/generateSkillTool.test.ts new file mode 100644 index 000000000..593ffd83a --- /dev/null +++ b/tests/mcp/tools/generateSkillTool.test.ts @@ -0,0 +1,254 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { runCli } from '../../../src/cli/cliRun.js'; +import { registerGenerateSkillTool } from '../../../src/mcp/tools/generateSkillTool.js'; +import { createMockConfig } from '../../testing/testUtils.js'; + +vi.mock('node:fs/promises'); +vi.mock('node:path'); +vi.mock('../../../src/cli/cliRun.js'); + +describe('GenerateSkillTool', () => { + const mockServer = { + registerTool: vi.fn().mockReturnThis(), + } as unknown as McpServer; + + let toolHandler: (args: { + directory: string; + skillName?: string; + compress?: boolean; + includePatterns?: string; + ignorePatterns?: string; + }) => Promise; + + const defaultPackResult = { + totalFiles: 10, + totalCharacters: 1000, + totalTokens: 500, + fileCharCounts: { 'test.js': 100 }, + fileTokenCounts: { 'test.js': 50 }, + suspiciousFilesResults: [], + gitDiffTokenCount: 0, + gitLogTokenCount: 0, + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + processedFiles: [], + safeFilePaths: [], + skippedFiles: [], + }; + + beforeEach(() => { + vi.resetAllMocks(); + registerGenerateSkillTool(mockServer); + toolHandler = (mockServer.registerTool as ReturnType).mock.calls[0][2]; + + // Default path mocks + vi.mocked(path.isAbsolute).mockReturnValue(true); + vi.mocked(path.join).mockImplementation((...args: string[]) => args.join('/')); + vi.mocked(path.basename).mockImplementation((p: string) => p.split('/').pop() || ''); + vi.mocked(path.resolve).mockImplementation((...args: string[]) => args.join('/')); + + // Default: directory doesn't exist (for skill dir check) + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + // Default runCli behavior + vi.mocked(runCli).mockImplementation(async (_directories, cwd, opts = {}) => ({ + packResult: defaultPackResult, + config: createMockConfig({ + cwd, + skillGenerate: opts.skillGenerate, + }), + })); + }); + + test('should register tool with correct parameters', () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'generate_skill', + expect.objectContaining({ + title: 'Generate Claude Agent Skill', + description: expect.stringContaining('Generate a Claude Agent Skill'), + }), + expect.any(Function), + ); + }); + + test('should reject relative paths', async () => { + vi.mocked(path.isAbsolute).mockReturnValue(false); + + const result = await toolHandler({ + directory: 'relative/path', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toContain('absolute path'); + }); + + test('should reject inaccessible directories', async () => { + vi.mocked(path.isAbsolute).mockReturnValue(true); + // First access check (directory) fails + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await toolHandler({ + directory: '/nonexistent/path', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toContain('not accessible'); + }); + + test('should reject when skill directory already exists', async () => { + vi.mocked(path.isAbsolute).mockReturnValue(true); + // First access check (directory) succeeds + vi.mocked(fs.access) + .mockResolvedValueOnce(undefined) // Directory exists + .mockResolvedValueOnce(undefined); // Skill directory also exists + + const result = await toolHandler({ + directory: '/test/project', + skillName: 'existing-skill', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toContain('already exists'); + }); + + test('should generate skill with custom name', async () => { + vi.mocked(path.isAbsolute).mockReturnValue(true); + // First access check (directory) succeeds + vi.mocked(fs.access) + .mockResolvedValueOnce(undefined) // Directory exists + .mockRejectedValueOnce(new Error('ENOENT')); // Skill directory doesn't exist + + const result = await toolHandler({ + directory: '/test/project', + skillName: 'my-custom-skill', + }); + + expect(result.isError).toBeUndefined(); + expect(runCli).toHaveBeenCalledWith( + ['.'], + '/test/project', + expect.objectContaining({ + skillGenerate: 'my-custom-skill', + skillName: 'my-custom-skill', + skillDir: expect.stringContaining('my-custom-skill'), + }), + ); + }); + + test('should generate skill with auto-generated name', async () => { + vi.mocked(fs.access) + .mockResolvedValueOnce(undefined) // Directory exists + .mockRejectedValueOnce(new Error('ENOENT')); // Skill directory doesn't exist + + await toolHandler({ + directory: '/test/project', + }); + + expect(runCli).toHaveBeenCalledWith( + ['.'], + '/test/project', + expect.objectContaining({ + skillGenerate: 'repomix-reference-project', + skillName: 'repomix-reference-project', + }), + ); + }); + + test('should pass compress option', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); + + await toolHandler({ + directory: '/test/project', + compress: true, + }); + + expect(runCli).toHaveBeenCalledWith( + ['.'], + '/test/project', + expect.objectContaining({ + compress: true, + }), + ); + }); + + test('should pass include and ignore patterns', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); + + await toolHandler({ + directory: '/test/project', + includePatterns: '**/*.ts', + ignorePatterns: 'test/**', + }); + + expect(runCli).toHaveBeenCalledWith( + ['.'], + '/test/project', + expect.objectContaining({ + include: '**/*.ts', + ignore: 'test/**', + }), + ); + }); + + test('should handle CLI execution failure', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runCli).mockResolvedValue(undefined); + + const result = await toolHandler({ + directory: '/test/project', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toBe('Failed to generate skill'); + }); + + test('should handle general error', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runCli).mockRejectedValue(new Error('Unexpected error')); + + const result = await toolHandler({ + directory: '/test/project', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toBe('Unexpected error'); + }); + + test('should return success response with skill info', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await toolHandler({ + directory: '/test/project', + skillName: 'test-skill', + }); + + expect(result.isError).toBeUndefined(); + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.skillName).toBe('test-skill'); + expect(parsedResult.totalFiles).toBe(10); + expect(parsedResult.totalTokens).toBe(500); + expect(parsedResult.description).toContain('Successfully generated'); + }); +}); From d1f64984dbffbf89183d24552903a45b29d5cb45 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:30:49 +0900 Subject: [PATCH 26/30] chore(typos): Add typos configuration to allow 'styl' extension The 'styl' is a valid file extension for Stylus CSS preprocessor. --- .typos.toml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .typos.toml diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 000000000..f74b2fec9 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,6 @@ +# Configuration for typos spell checker +# https://github.com/crate-ci/typos + +[default.extend-words] +# "styl" is the file extension for Stylus CSS preprocessor +styl = "styl" From 56886674c56f715c34d9fb4a77d6f21c6d44c9d7 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:33:21 +0900 Subject: [PATCH 27/30] fix(typos): Rename .typos.toml to _typos.toml The typos spell checker expects the config file to be named _typos.toml (with underscore) not .typos.toml (with dot). --- .typos.toml => _typos.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .typos.toml => _typos.toml (100%) diff --git a/.typos.toml b/_typos.toml similarity index 100% rename from .typos.toml rename to _typos.toml From f8f476fbb85066a7fcff18ee1140051aaa831d03 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:37:02 +0900 Subject: [PATCH 28/30] fix(typos): Add 'styl' to extend-words in existing typos.toml Move styl exception from _typos.toml to the existing typos.toml file. The 'styl' word is the file extension for Stylus CSS preprocessor. --- _typos.toml | 6 ------ typos.toml | 4 ++++ 2 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 _typos.toml diff --git a/_typos.toml b/_typos.toml deleted file mode 100644 index f74b2fec9..000000000 --- a/_typos.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Configuration for typos spell checker -# https://github.com/crate-ci/typos - -[default.extend-words] -# "styl" is the file extension for Stylus CSS preprocessor -styl = "styl" diff --git a/typos.toml b/typos.toml index efc6a7cdc..ad1e58ff3 100644 --- a/typos.toml +++ b/typos.toml @@ -11,3 +11,7 @@ ignore-hidden = false extend-ignore-re = [ "Việt Nam", ] + +[default.extend-words] +# "styl" is the file extension for Stylus CSS preprocessor +styl = "styl" From 59a42e615aa19eb6e59350527ea3720e5711c5fc Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Thu, 11 Dec 2025 23:56:03 +0900 Subject: [PATCH 29/30] refactor(skill): Improve code quality based on review feedback - Replace process.exit() with OperationCancelledError in skillPrompts.ts - Centralize Handlebars helper registration in outputStyleUtils.ts - Add path normalization validation in generateSkillTool.ts - Set idempotentHint to true for generate_skill MCP tool - Add skillGenerate config merge tests in configLoad.test.ts - Add path normalization test in generateSkillTool.test.ts --- src/cli/prompts/skillPrompts.ts | 5 ++- src/core/output/outputStyleUtils.ts | 20 ++++++++++ src/core/output/outputStyles/markdownStyle.ts | 10 ++--- src/core/skill/skillSectionGenerators.ts | 12 ++---- src/mcp/tools/generateSkillTool.ts | 10 ++++- src/shared/errorHandle.ts | 7 ++++ tests/cli/prompts/skillPrompts.test.ts | 37 +++++++------------ tests/config/configLoad.test.ts | 22 +++++++++++ tests/mcp/tools/generateSkillTool.test.ts | 17 +++++++++ 9 files changed, 99 insertions(+), 41 deletions(-) diff --git a/src/cli/prompts/skillPrompts.ts b/src/cli/prompts/skillPrompts.ts index 49f47c4ad..f64f30882 100644 --- a/src/cli/prompts/skillPrompts.ts +++ b/src/cli/prompts/skillPrompts.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import * as prompts from '@clack/prompts'; import pc from 'picocolors'; +import { OperationCancelledError } from '../../shared/errorHandle.js'; import { getDisplayPath } from '../cliReport.js'; export type SkillLocation = 'personal' | 'project'; @@ -12,9 +13,9 @@ export interface SkillPromptResult { skillDir: string; } -const onCancelOperation = () => { +const onCancelOperation = (): never => { prompts.cancel('Skill generation cancelled.'); - process.exit(0); + throw new OperationCancelledError('Skill generation cancelled'); }; /** diff --git a/src/core/output/outputStyleUtils.ts b/src/core/output/outputStyleUtils.ts index 5b844d19f..a2730d9c3 100644 --- a/src/core/output/outputStyleUtils.ts +++ b/src/core/output/outputStyleUtils.ts @@ -1,6 +1,7 @@ /** * Shared utilities for output style generation. */ +import Handlebars from 'handlebars'; /** * Map of file extensions to syntax highlighting language names. @@ -221,3 +222,22 @@ export const getLanguageFromFilePath = (filePath: string): string => { const extension = filePath.split('.').pop()?.toLowerCase(); return extension ? extensionToLanguageMap[extension] || '' : ''; }; + +// Track if Handlebars helpers have been registered +let handlebarsHelpersRegistered = false; + +/** + * Register common Handlebars helpers for output generation. + * This function is idempotent - calling it multiple times has no effect. + */ +export const registerHandlebarsHelpers = (): void => { + if (handlebarsHelpersRegistered) { + return; + } + + Handlebars.registerHelper('getFileExtension', (filePath: string) => { + return getLanguageFromFilePath(filePath); + }); + + handlebarsHelpersRegistered = true; +}; diff --git a/src/core/output/outputStyles/markdownStyle.ts b/src/core/output/outputStyles/markdownStyle.ts index c16dec653..050beb75f 100644 --- a/src/core/output/outputStyles/markdownStyle.ts +++ b/src/core/output/outputStyles/markdownStyle.ts @@ -1,5 +1,7 @@ -import Handlebars from 'handlebars'; -import { getLanguageFromFilePath } from '../outputStyleUtils.js'; +import { registerHandlebarsHelpers } from '../outputStyleUtils.js'; + +// Register Handlebars helpers (idempotent) +registerHandlebarsHelpers(); export const getMarkdownTemplate = () => { return /* md */ ` @@ -83,7 +85,3 @@ export const getMarkdownTemplate = () => { {{/if}} `; }; - -Handlebars.registerHelper('getFileExtension', (filePath: string) => { - return getLanguageFromFilePath(filePath); -}); diff --git a/src/core/skill/skillSectionGenerators.ts b/src/core/skill/skillSectionGenerators.ts index 4b4f009c0..1a5e2bfdb 100644 --- a/src/core/skill/skillSectionGenerators.ts +++ b/src/core/skill/skillSectionGenerators.ts @@ -1,7 +1,10 @@ import Handlebars from 'handlebars'; import { generateTreeStringWithLineCounts } from '../file/fileTreeGenerate.js'; import type { RenderContext } from '../output/outputGeneratorTypes.js'; -import { getLanguageFromFilePath } from '../output/outputStyleUtils.js'; +import { registerHandlebarsHelpers } from '../output/outputStyleUtils.js'; + +// Register Handlebars helpers (idempotent) +registerHandlebarsHelpers(); /** * Generates the summary section for skill output. @@ -76,13 +79,6 @@ export const generateFilesSection = (context: RenderContext): string => { return ''; } - // Register the helper if not already registered - if (!Handlebars.helpers.getFileExtension) { - Handlebars.registerHelper('getFileExtension', (filePath: string) => { - return getLanguageFromFilePath(filePath); - }); - } - const template = Handlebars.compile(`# Files {{#each processedFiles}} diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index 9c45612cd..df12e35ca 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -73,7 +73,7 @@ Example Paths: annotations: { readOnlyHint: false, destructiveHint: false, - idempotentHint: false, + idempotentHint: true, openWorldHint: false, }, }, @@ -86,6 +86,14 @@ Example Paths: }); } + // Validate directory path is normalized (no .., ., or redundant separators) + const normalizedDirectory = path.normalize(directory); + if (normalizedDirectory !== directory) { + return buildMcpToolErrorResponse({ + errorMessage: `Directory path must be normalized. Use "${normalizedDirectory}" instead of "${directory}"`, + }); + } + // Check if directory exists and is accessible try { await fs.access(directory); diff --git a/src/shared/errorHandle.ts b/src/shared/errorHandle.ts index 485afa2b4..928cc41a0 100644 --- a/src/shared/errorHandle.ts +++ b/src/shared/errorHandle.ts @@ -17,6 +17,13 @@ export class RepomixConfigValidationError extends RepomixError { } } +export class OperationCancelledError extends RepomixError { + constructor(message = 'Operation cancelled') { + super(message); + this.name = 'OperationCancelledError'; + } +} + export const handleError = (error: unknown): void => { logger.log(''); diff --git a/tests/cli/prompts/skillPrompts.test.ts b/tests/cli/prompts/skillPrompts.test.ts index f5c6cb33c..e3f32d342 100644 --- a/tests/cli/prompts/skillPrompts.test.ts +++ b/tests/cli/prompts/skillPrompts.test.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import path from 'node:path'; import { describe, expect, test, vi } from 'vitest'; import { getSkillBaseDir, promptSkillLocation } from '../../../src/cli/prompts/skillPrompts.js'; +import { OperationCancelledError } from '../../../src/shared/errorHandle.js'; // Helper to create mock deps with proper typing const createMockDeps = (overrides: { @@ -74,27 +75,19 @@ describe('skillPrompts', () => { expect(result.location).toBe('personal'); }); - test('should exit when select is cancelled', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - + test('should throw OperationCancelledError when select is cancelled', async () => { const mockDeps = createMockDeps({ selectValue: Symbol('cancel'), isCancelFn: () => true, accessRejects: true, }); - await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); - - mockExit.mockRestore(); + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow( + OperationCancelledError, + ); }); - test('should exit when overwrite is declined', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - + test('should throw OperationCancelledError when overwrite is declined', async () => { const mockDeps = createMockDeps({ selectValue: 'personal', confirmValue: false, @@ -102,16 +95,12 @@ describe('skillPrompts', () => { accessRejects: false, // Directory exists }); - await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); - - mockExit.mockRestore(); + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow( + OperationCancelledError, + ); }); - test('should exit when confirm is cancelled', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - + test('should throw OperationCancelledError when confirm is cancelled', async () => { let callCount = 0; const mockDeps = createMockDeps({ selectValue: 'personal', @@ -124,9 +113,9 @@ describe('skillPrompts', () => { accessRejects: false, // Directory exists }); - await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow('process.exit called'); - - mockExit.mockRestore(); + await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow( + OperationCancelledError, + ); }); }); }); diff --git a/tests/config/configLoad.test.ts b/tests/config/configLoad.test.ts index d864ab096..aa221e7b4 100644 --- a/tests/config/configLoad.test.ts +++ b/tests/config/configLoad.test.ts @@ -329,5 +329,27 @@ describe('configLoad', () => { expect(merged.output.filePath).toBe('repomix-output.txt'); expect(merged.output.style).toBe('plain'); }); + + test('should merge skillGenerate boolean from CLI config', () => { + const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: true }); + expect(merged.skillGenerate).toBe(true); + }); + + test('should merge skillGenerate string from CLI config', () => { + const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: 'my-custom-skill' }); + expect(merged.skillGenerate).toBe('my-custom-skill'); + }); + + test('should not include skillGenerate in merged config when undefined', () => { + const merged = mergeConfigs(process.cwd(), {}, {}); + expect(merged.skillGenerate).toBeUndefined(); + }); + + test('should not allow skillGenerate from file config (CLI-only option)', () => { + // File config should not have skillGenerate - it's CLI-only + // This test verifies that even if somehow passed, file config doesn't affect it + const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: 'from-cli' }); + expect(merged.skillGenerate).toBe('from-cli'); + }); }); }); diff --git a/tests/mcp/tools/generateSkillTool.test.ts b/tests/mcp/tools/generateSkillTool.test.ts index 593ffd83a..b91924b77 100644 --- a/tests/mcp/tools/generateSkillTool.test.ts +++ b/tests/mcp/tools/generateSkillTool.test.ts @@ -50,6 +50,7 @@ describe('GenerateSkillTool', () => { vi.mocked(path.join).mockImplementation((...args: string[]) => args.join('/')); vi.mocked(path.basename).mockImplementation((p: string) => p.split('/').pop() || ''); vi.mocked(path.resolve).mockImplementation((...args: string[]) => args.join('/')); + vi.mocked(path.normalize).mockImplementation((p: string) => p); // Default: directory doesn't exist (for skill dir check) vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); @@ -89,6 +90,22 @@ describe('GenerateSkillTool', () => { expect(parsedResult.errorMessage).toContain('absolute path'); }); + test('should reject non-normalized paths', async () => { + vi.mocked(path.isAbsolute).mockReturnValue(true); + vi.mocked(path.normalize).mockReturnValue('/test/project'); + + const result = await toolHandler({ + directory: '/test/../test/project', + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content.type).toBe('text'); + const parsedResult = JSON.parse((content as { type: 'text'; text: string }).text); + expect(parsedResult.errorMessage).toContain('must be normalized'); + expect(parsedResult.errorMessage).toContain('/test/project'); + }); + test('should reject inaccessible directories', async () => { vi.mocked(path.isAbsolute).mockReturnValue(true); // First access check (directory) fails From 7e5a431f4edbc5a6625fb61857a03b54e73364f9 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Fri, 12 Dec 2025 00:04:22 +0900 Subject: [PATCH 30/30] fix(skill): Address PR review feedback - Fix line count logic for edge cases (empty files return 0, trailing newlines don't add extra line count) - Move skillDir validation earlier in packSkill.ts for fail-fast behavior - Normalize user-provided skillName in generateSkillTool.ts using validateSkillName() for consistent kebab-case format - Update generateSkillTool description to reflect project-only behavior (removed misleading personal skill documentation) --- src/core/output/outputGenerate.ts | 12 ++++++++++-- src/core/skill/packSkill.ts | 12 ++++++------ src/mcp/tools/generateSkillTool.ts | 14 ++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 66f3799b0..a2ab0abfb 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -33,8 +33,16 @@ const calculateMarkdownDelimiter = (files: ReadonlyArray): string const calculateFileLineCounts = (processedFiles: ProcessedFile[]): Record => { const lineCounts: Record = {}; for (const file of processedFiles) { - // Count lines by splitting on newlines - lineCounts[file.path] = file.content.split('\n').length; + // Count lines: empty files have 0 lines, otherwise count newlines + 1 + // (unless the content ends with a newline, in which case the last "line" is empty) + const content = file.content; + if (content.length === 0) { + lineCounts[file.path] = 0; + } else { + // Count actual lines (text editor style: number of \n + 1, but trailing \n doesn't add extra line) + const newlineCount = (content.match(/\n/g) || []).length; + lineCounts[file.path] = content.endsWith('\n') ? newlineCount : newlineCount + 1; + } } return lineCounts; }; diff --git a/src/core/skill/packSkill.ts b/src/core/skill/packSkill.ts index 4388c49bf..37dd20696 100644 --- a/src/core/skill/packSkill.ts +++ b/src/core/skill/packSkill.ts @@ -199,6 +199,12 @@ export const packSkill = async (params: PackSkillParams, deps = defaultDeps): Pr progressCallback, } = params; + // Validate skillDir early to fail fast (before expensive operations) + const { skillDir } = options; + if (!skillDir) { + throw new Error('skillDir is required for skill generation'); + } + // Use pre-computed skill name or generate from directories const skillName = options.skillName ?? @@ -227,12 +233,6 @@ export const packSkill = async (params: PackSkillParams, deps = defaultDeps): Pr // Step 3: Generate SKILL.md with accurate token count const skillOutput = generateSkillMdFromReferences(skillReferencesResult, skillMetrics.totalTokens); - // Validate skillDir (should always be set when packSkill is called) - const { skillDir } = options; - if (!skillDir) { - throw new Error('skillDir is required for skill generation'); - } - progressCallback('Writing skill output...'); await withMemoryLogging('Write Skill Output', () => deps.writeSkillOutput(skillOutput, skillDir)); diff --git a/src/mcp/tools/generateSkillTool.ts b/src/mcp/tools/generateSkillTool.ts index df12e35ca..64a9a2491 100644 --- a/src/mcp/tools/generateSkillTool.ts +++ b/src/mcp/tools/generateSkillTool.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import { runCli } from '../../cli/cliRun.js'; import { getSkillBaseDir } from '../../cli/prompts/skillPrompts.js'; import type { CliOptions } from '../../cli/types.js'; -import { generateDefaultSkillName } from '../../core/skill/skillUtils.js'; +import { generateDefaultSkillName, validateSkillName } from '../../core/skill/skillUtils.js'; import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, convertErrorToJson } from './mcpToolRuntime.js'; const generateSkillInputSchema = z.object({ @@ -52,9 +52,7 @@ export const registerGenerateSkillTool = (mcpServer: McpServer) => { title: 'Generate Claude Agent Skill', description: `Generate a Claude Agent Skill from a local code directory. Creates a skill package containing SKILL.md (entry point with metadata) and references/ folder with summary.md, project-structure.md, files.md, and optionally tech-stack.md. -Skill Types: -- Project Skills: Created in /.claude/skills// - shared with the team via version control -- Personal Skills: Created in ~/.claude/skills// - private to your machine +This tool creates Project Skills in /.claude/skills//, which are shared with the team via version control. Output Structure: .claude/skills// @@ -65,9 +63,8 @@ Output Structure: ├── files.md # All file contents └── tech-stack.md # Languages, frameworks, dependencies (if detected) -Example Paths: -- Project: /path/to/project/.claude/skills/repomix-reference-myproject/ -- Personal: ~/.claude/skills/repomix-reference-myproject/`, +Example Path: + /path/to/project/.claude/skills/repomix-reference-myproject/`, inputSchema: generateSkillInputSchema, outputSchema: generateSkillOutputSchema, annotations: { @@ -105,7 +102,8 @@ Example Paths: // Pre-compute skill name and directory to avoid interactive prompts // MCP is non-interactive, so we must specify skillDir explicitly - const actualSkillName = skillName ?? generateDefaultSkillName([directory]); + // Normalize user-provided skill name to ensure consistent kebab-case format + const actualSkillName = skillName ? validateSkillName(skillName) : generateDefaultSkillName([directory]); const skillDir = path.join(getSkillBaseDir(directory, 'project'), actualSkillName); // Check if skill directory already exists (MCP cannot prompt for overwrite)