From 43c2bd81da8dcc817558300470f048a556229e51 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Fri, 12 Dec 2025 20:54:01 +0900 Subject: [PATCH 1/5] feat(skill): Improve skill generation for remote repositories - Fix project name generation to use repository name from URL instead of temp directory name (e.g., "Vite" instead of "Repomix HPkbgH") - Add source URL to SKILL.md footer for remote repositories only (local repos don't include URL to avoid exposing personal paths) - Copy only .claude/skills/ directory to prevent conflicts with repository's own .claude config (commands, agents, etc.) Example SKILL.md footer: - Remote: "This skill was generated by Repomix from [https://github.com/...]" - Local: "This skill was generated by Repomix" --- src/cli/actions/remoteAction.ts | 31 ++++--- .../actions/workers/defaultActionWorker.ts | 4 +- src/cli/types.ts | 2 + src/core/packager.ts | 2 + src/core/skill/packSkill.ts | 28 ++++-- src/core/skill/skillStyle.ts | 7 +- src/core/skill/skillUtils.ts | 14 +++ tests/core/skill/packSkill.test.ts | 85 +++++++++++++++++++ tests/core/skill/skillUtils.test.ts | 23 +++++ 9 files changed, 175 insertions(+), 21 deletions(-) diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 6cb32ee83..b02bd7647 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/skill/skillUtils.js'; +import { generateDefaultSkillNameFromUrl, generateProjectNameFromUrl } from '../../core/skill/skillUtils.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { Spinner } from '../cliSpinner.js'; @@ -94,20 +94,25 @@ export const runRemoteAction = async ( let skillName: string | undefined; let skillDir: string | undefined; let skillLocation: SkillLocation | undefined; + let skillProjectName: string | undefined; if (cliOptions.skillGenerate !== undefined) { skillName = typeof cliOptions.skillGenerate === 'string' ? cliOptions.skillGenerate : generateDefaultSkillNameFromUrl(repoUrl); + // Generate project name from URL for use in skill description + skillProjectName = generateProjectNameFromUrl(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 pre-computed skill name and directory - const optionsWithSkill = { ...cliOptions, skillName, skillDir }; + // Pass the pre-computed skill name, directory, project name, and source URL + const skillSourceUrl = cliOptions.skillGenerate !== undefined ? repoUrl : undefined; + const optionsWithSkill = { ...cliOptions, skillName, skillDir, skillProjectName, skillSourceUrl }; result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithSkill); // Copy output to current directory @@ -209,29 +214,31 @@ export const cleanupTempDirectory = async (directory: string): Promise => }; export const copySkillOutputToCurrentDirectory = async (sourceDir: string, targetDir: string): Promise => { - const sourceClaudeDir = path.join(sourceDir, '.claude'); - const targetClaudeDir = path.join(targetDir, '.claude'); + // Only copy .claude/skills/ directory, not the entire .claude directory + // This prevents conflicts with repository's own .claude config (commands, agents, etc.) + const sourceSkillsDir = path.join(sourceDir, '.claude', 'skills'); + const targetSkillsDir = path.join(targetDir, '.claude', 'skills'); try { - // Check if source .claude directory exists - await fs.access(sourceClaudeDir); + // Check if source .claude/skills directory exists + await fs.access(sourceSkillsDir); } catch { // No skill output was generated - logger.trace('No .claude directory found in source, skipping skill output copy'); + logger.trace('No .claude/skills directory found in source, skipping skill output copy'); return; } try { - logger.trace(`Copying skill output from: ${sourceClaudeDir} to: ${targetClaudeDir}`); + logger.trace(`Copying skill output from: ${sourceSkillsDir} to: ${targetSkillsDir}`); - // Copy the entire .claude directory - await fs.cp(sourceClaudeDir, targetClaudeDir, { recursive: true }); + // Copy only the skills directory + await fs.cp(sourceSkillsDir, targetSkillsDir, { 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. + `Failed to copy skill output to ${targetSkillsDir}: 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).`, diff --git a/src/cli/actions/workers/defaultActionWorker.ts b/src/cli/actions/workers/defaultActionWorker.ts index bc20b88e5..f1d2b9ec0 100644 --- a/src/cli/actions/workers/defaultActionWorker.ts +++ b/src/cli/actions/workers/defaultActionWorker.ts @@ -55,8 +55,8 @@ async function defaultActionWorker( let packResult: PackResult; try { - const { skillName, skillDir } = cliOptions; - const packOptions = { skillName, skillDir }; + const { skillName, skillDir, skillProjectName, skillSourceUrl } = cliOptions; + const packOptions = { skillName, skillDir, skillProjectName, skillSourceUrl }; if (stdinFilePaths) { // Handle stdin processing with file paths from main process diff --git a/src/cli/types.ts b/src/cli/types.ts index 3c19df565..e2736747c 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -59,6 +59,8 @@ export interface CliOptions extends OptionValues { 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) + skillProjectName?: string; // Pre-computed project name for skill description (used internally for remote repos) + skillSourceUrl?: string; // Source URL for skill (used internally for remote repos only) // Other Options topFilesLen?: number; diff --git a/src/core/packager.ts b/src/core/packager.ts index ba0d712f7..be0ac38ee 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -50,6 +50,8 @@ const defaultDeps = { export interface PackOptions { skillName?: string; skillDir?: string; + skillProjectName?: string; + skillSourceUrl?: string; } export const pack = async ( diff --git a/src/core/skill/packSkill.ts b/src/core/skill/packSkill.ts index 37dd20696..c70232317 100644 --- a/src/core/skill/packSkill.ts +++ b/src/core/skill/packSkill.ts @@ -44,6 +44,7 @@ export interface SkillReferencesResult { totalLines: number; statisticsSection: string; hasTechStack: boolean; + sourceUrl?: string; } /** @@ -74,6 +75,8 @@ export const generateSkillReferences = async ( allFilePaths: string[], gitDiffResult: GitDiffResult | undefined = undefined, gitLogResult: GitLogResult | undefined = undefined, + skillProjectName?: string, + skillSourceUrl?: string, deps = { buildOutputGeneratorContext, sortOutputFiles, @@ -82,8 +85,8 @@ export const generateSkillReferences = async ( // Validate and normalize skill name const normalizedSkillName = validateSkillName(skillName); - // Generate project name from root directories - const projectName = generateProjectName(rootDirs); + // Use provided project name or generate from root directories + const projectName = skillProjectName ?? generateProjectName(rootDirs); // Generate skill description const skillDescription = generateSkillDescription(normalizedSkillName, projectName); @@ -135,6 +138,7 @@ export const generateSkillReferences = async ( totalLines: statistics.totalLines, statisticsSection, hasTechStack: techStack !== null, + sourceUrl: skillSourceUrl, }; }; @@ -154,6 +158,7 @@ export const generateSkillMdFromReferences = ( totalLines: referencesResult.totalLines, totalTokens, hasTechStack: referencesResult.hasTechStack, + sourceUrl: referencesResult.sourceUrl, }); return { @@ -212,10 +217,21 @@ export const packSkill = async (params: PackSkillParams, deps = defaultDeps): Pr // 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, - }), + generateSkillReferences( + skillName, + rootDirs, + config, + processedFiles, + allFilePaths, + gitDiffResult, + gitLogResult, + options.skillProjectName, + options.skillSourceUrl, + { + buildOutputGeneratorContext: deps.buildOutputGeneratorContext, + sortOutputFiles: deps.sortOutputFiles, + }, + ), ); // Step 2: Calculate metrics from files section to get accurate token count diff --git a/src/core/skill/skillStyle.ts b/src/core/skill/skillStyle.ts index ef6e2f239..743d9807c 100644 --- a/src/core/skill/skillStyle.ts +++ b/src/core/skill/skillStyle.ts @@ -8,6 +8,7 @@ export interface SkillRenderContext { totalLines: number; totalTokens: number; hasTechStack: boolean; + sourceUrl?: string; } /** @@ -37,12 +38,12 @@ Use this skill when you need to: | File | Contents | |------|----------| +| \`references/summary.md\` | **Start here** - Purpose, format explanation, and statistics | | \`references/project-structure.md\` | Directory tree with line counts per 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, format explanation, and statistics | ## How to Use @@ -95,6 +96,10 @@ function calculateTotal {{#if hasTechStack}} - Check \`tech-stack.md\` for languages, frameworks, and dependencies {{/if}} + +--- + +This skill was generated by [Repomix](https://github.com/yamadashy/repomix){{#if sourceUrl}} from [{{{sourceUrl}}}]({{{sourceUrl}}}){{/if}} `; }; diff --git a/src/core/skill/skillUtils.ts b/src/core/skill/skillUtils.ts index 62b6a155d..24ea50bf3 100644 --- a/src/core/skill/skillUtils.ts +++ b/src/core/skill/skillUtils.ts @@ -68,6 +68,20 @@ export const generateSkillDescription = (_skillName: string, projectName: string return description.substring(0, SKILL_DESCRIPTION_MAX_LENGTH); }; +/** + * Generates a human-readable project name from a remote URL. + * Uses the repository name extracted from the URL, converted to Title Case. + */ +export const generateProjectNameFromUrl = (remoteUrl: string): string => { + const repoName = extractRepoName(remoteUrl); + + // Convert kebab-case or snake_case to Title Case + return repoName + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +}; + /** * Extracts repository name from a URL or shorthand format. * Examples: diff --git a/tests/core/skill/packSkill.test.ts b/tests/core/skill/packSkill.test.ts index 397792b58..ed746a738 100644 --- a/tests/core/skill/packSkill.test.ts +++ b/tests/core/skill/packSkill.test.ts @@ -41,6 +41,8 @@ describe('packSkill', () => { ['src/index.ts', 'src/utils.ts', 'package.json'], undefined, undefined, + undefined, + undefined, mockDeps, ); @@ -76,12 +78,46 @@ describe('packSkill', () => { [], undefined, undefined, + undefined, + undefined, mockDeps, ); expect(result.skillName).toBe('my-test-skill'); }); + test('should use provided skillProjectName when available', 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( + 'test-skill', + ['/tmp/repomix-abc123'], // Temp directory that would generate bad name + mockConfig, + mockFiles, + [], + undefined, + undefined, + 'Vite', // Provided skillProjectName + undefined, + mockDeps, + ); + + expect(result.projectName).toBe('Vite'); + expect(result.skillDescription).toContain('Vite'); + }); + test('should detect tech stack when available', async () => { const mockConfig = createMockConfig(); const mockFiles: ProcessedFile[] = [{ path: 'package.json', content: '{"dependencies": {"react": "^18.0.0"}}' }]; @@ -105,6 +141,8 @@ describe('packSkill', () => { ['package.json'], undefined, undefined, + undefined, + undefined, mockDeps, ); @@ -135,6 +173,8 @@ describe('packSkill', () => { [], undefined, undefined, + undefined, + undefined, mockDeps, ); @@ -192,6 +232,51 @@ describe('packSkill', () => { expect(result.skillMd).toContain('test-skill'); expect(result.references.techStack).toBeUndefined(); }); + + test('should include source URL when provided', () => { + 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, + sourceUrl: 'https://github.com/vitejs/vite', + }; + + const result = generateSkillMdFromReferences(referencesResult, 500); + + expect(result.skillMd).toContain('https://github.com/vitejs/vite'); + expect(result.skillMd).toContain('from [https://github.com/vitejs/vite]'); + }); + + test('should not include source URL when not provided', () => { + 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('This skill was generated by [Repomix]'); + expect(result.skillMd).not.toContain('from ['); + }); }); describe('packSkill', () => { diff --git a/tests/core/skill/skillUtils.test.ts b/tests/core/skill/skillUtils.test.ts index 04632d91b..81f9261d8 100644 --- a/tests/core/skill/skillUtils.test.ts +++ b/tests/core/skill/skillUtils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { generateProjectName, + generateProjectNameFromUrl, generateSkillDescription, toKebabCase, validateSkillName, @@ -90,6 +91,28 @@ describe('skillUtils', () => { }); }); + describe('generateProjectNameFromUrl', () => { + test('should extract and convert GitHub URL to title case', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite')).toBe('Vite'); + }); + + test('should handle kebab-case repo names', () => { + expect(generateProjectNameFromUrl('https://github.com/yamadashy/repomix')).toBe('Repomix'); + }); + + test('should handle snake_case repo names', () => { + expect(generateProjectNameFromUrl('https://github.com/user/my_project_name')).toBe('My Project Name'); + }); + + test('should handle shorthand format', () => { + expect(generateProjectNameFromUrl('yamadashy/repomix')).toBe('Repomix'); + }); + + test('should handle .git suffix', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite.git')).toBe('Vite'); + }); + }); + describe('generateProjectName', () => { test('should convert directory name to title case', () => { expect(generateProjectName(['my-project'])).toBe('My Project'); From 90ffa5b68212f3585e7e3f6f139083ff32819b2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Dec 2025 11:57:00 +0000 Subject: [PATCH 2/5] chore(schema): auto generate schema --- .../src/public/schemas/1.10.0/schema.json | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 website/client/src/public/schemas/1.10.0/schema.json diff --git a/website/client/src/public/schemas/1.10.0/schema.json b/website/client/src/public/schemas/1.10.0/schema.json new file mode 100644 index 000000000..53d1e305d --- /dev/null +++ b/website/client/src/public/schemas/1.10.0/schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "input": { + "type": "object", + "properties": { + "maxFileSize": { + "type": "number" + } + }, + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "style": { + "type": "string", + "enum": [ + "xml", + "markdown", + "json", + "plain" + ] + }, + "parsableStyle": { + "type": "boolean" + }, + "headerText": { + "type": "string" + }, + "instructionFilePath": { + "type": "string" + }, + "fileSummary": { + "type": "boolean" + }, + "directoryStructure": { + "type": "boolean" + }, + "files": { + "type": "boolean" + }, + "removeComments": { + "type": "boolean" + }, + "removeEmptyLines": { + "type": "boolean" + }, + "compress": { + "type": "boolean" + }, + "topFilesLength": { + "type": "number" + }, + "showLineNumbers": { + "type": "boolean" + }, + "truncateBase64": { + "type": "boolean" + }, + "copyToClipboard": { + "type": "boolean" + }, + "includeEmptyDirectories": { + "type": "boolean" + }, + "includeFullDirectoryStructure": { + "type": "boolean" + }, + "tokenCountTree": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "git": { + "type": "object", + "properties": { + "sortByChanges": { + "type": "boolean" + }, + "sortByChangesMaxCommits": { + "type": "number" + }, + "includeDiffs": { + "type": "boolean" + }, + "includeLogs": { + "type": "boolean" + }, + "includeLogsCount": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "ignore": { + "type": "object", + "properties": { + "useGitignore": { + "type": "boolean" + }, + "useDotIgnore": { + "type": "boolean" + }, + "useDefaultPatterns": { + "type": "boolean" + }, + "customPatterns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "security": { + "type": "object", + "properties": { + "enableSecurityCheck": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "tokenCount": { + "type": "object", + "properties": { + "encoding": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "title": "Repomix Configuration", + "description": "Schema for repomix.config.json configuration file" +} \ No newline at end of file From dc694e7372c975e4f310004eb0a3783d54f98a79 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Fri, 12 Dec 2025 21:01:51 +0900 Subject: [PATCH 3/5] test(skill): Add tests for copySkillOutputToCurrentDirectory Add unit tests for the copySkillOutputToCurrentDirectory function: - Copy .claude/skills directory when it exists - Skip copy when directory does not exist - Handle EPERM and EACCES permission errors - Handle generic errors --- tests/cli/actions/remoteAction.test.ts | 76 +++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/cli/actions/remoteAction.test.ts b/tests/cli/actions/remoteAction.test.ts index c8736458e..7ebc317bd 100644 --- a/tests/cli/actions/remoteAction.test.ts +++ b/tests/cli/actions/remoteAction.test.ts @@ -2,14 +2,20 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { DefaultActionRunnerResult } from '../../../src/cli/actions/defaultAction.js'; -import { copyOutputToCurrentDirectory, runRemoteAction } from '../../../src/cli/actions/remoteAction.js'; +import { + copyOutputToCurrentDirectory, + copySkillOutputToCurrentDirectory, + runRemoteAction, +} from '../../../src/cli/actions/remoteAction.js'; import { createMockConfig } from '../../testing/testUtils.js'; vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + access: vi.fn(), copyFile: vi.fn(), + cp: vi.fn(), mkdir: vi.fn(), }; }); @@ -285,4 +291,72 @@ describe('remoteAction functions', () => { ); }); }); + + describe('copySkillOutputToCurrentDirectory', () => { + test('should copy .claude/skills directory when it exists', async () => { + const sourceDir = '/tmp/repomix-123'; + const targetDir = '/target/dir'; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.cp).mockResolvedValue(undefined); + + await copySkillOutputToCurrentDirectory(sourceDir, targetDir); + + expect(fs.access).toHaveBeenCalledWith(path.join(sourceDir, '.claude', 'skills')); + expect(fs.cp).toHaveBeenCalledWith( + path.join(sourceDir, '.claude', 'skills'), + path.join(targetDir, '.claude', 'skills'), + { recursive: true }, + ); + }); + + test('should skip copy when .claude/skills directory does not exist', async () => { + const sourceDir = '/tmp/repomix-123'; + const targetDir = '/target/dir'; + + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + vi.mocked(fs.cp).mockResolvedValue(undefined); + + await copySkillOutputToCurrentDirectory(sourceDir, targetDir); + + expect(fs.access).toHaveBeenCalledWith(path.join(sourceDir, '.claude', 'skills')); + expect(fs.cp).not.toHaveBeenCalled(); + }); + + test('should throw helpful error message for EPERM permission errors', async () => { + const sourceDir = '/tmp/repomix-123'; + const targetDir = '/protected/dir'; + + vi.mocked(fs.access).mockResolvedValue(undefined); + const epermError = new Error('operation not permitted') as NodeJS.ErrnoException; + epermError.code = 'EPERM'; + vi.mocked(fs.cp).mockRejectedValue(epermError); + + await expect(copySkillOutputToCurrentDirectory(sourceDir, targetDir)).rejects.toThrow(/Permission denied/); + }); + + test('should throw helpful error message for EACCES permission errors', async () => { + const sourceDir = '/tmp/repomix-123'; + const targetDir = '/protected/dir'; + + vi.mocked(fs.access).mockResolvedValue(undefined); + const eaccesError = new Error('permission denied') as NodeJS.ErrnoException; + eaccesError.code = 'EACCES'; + vi.mocked(fs.cp).mockRejectedValue(eaccesError); + + await expect(copySkillOutputToCurrentDirectory(sourceDir, targetDir)).rejects.toThrow(/Permission denied/); + }); + + test('should throw generic error for other failures', async () => { + const sourceDir = '/tmp/repomix-123'; + const targetDir = '/target/dir'; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.cp).mockRejectedValue(new Error('Disk full')); + + await expect(copySkillOutputToCurrentDirectory(sourceDir, targetDir)).rejects.toThrow( + 'Failed to copy skill output: Disk full', + ); + }); + }); }); From 925d94adf909fd66dd6961b0974f909583f21dfc Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Fri, 12 Dec 2025 21:49:14 +0900 Subject: [PATCH 4/5] refactor(skill): Address PR review feedback - Fix extractRepoName to handle trailing slashes, query strings, and fragments in URLs (e.g., https://github.com/user/repo/ now works) - Extract toTitleCase helper function to reduce code duplication - Use projectName instead of sourceUrl for link text in SKILL.md footer (displays as "from [Vite](https://...)" instead of full URL twice) - Add tests for URL edge cases (trailing slash, query, fragment) --- src/core/skill/skillStyle.ts | 2 +- src/core/skill/skillUtils.ts | 34 +++++++++++++++++------------ tests/core/skill/packSkill.test.ts | 2 +- tests/core/skill/skillUtils.test.ts | 20 +++++++++++++++++ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/core/skill/skillStyle.ts b/src/core/skill/skillStyle.ts index 743d9807c..965a88696 100644 --- a/src/core/skill/skillStyle.ts +++ b/src/core/skill/skillStyle.ts @@ -99,7 +99,7 @@ function calculateTotal --- -This skill was generated by [Repomix](https://github.com/yamadashy/repomix){{#if sourceUrl}} from [{{{sourceUrl}}}]({{{sourceUrl}}}){{/if}} +This skill was generated by [Repomix](https://github.com/yamadashy/repomix){{#if sourceUrl}} from [{{{projectName}}}]({{{sourceUrl}}}){{/if}} `; }; diff --git a/src/core/skill/skillUtils.ts b/src/core/skill/skillUtils.ts index 24ea50bf3..cb7754809 100644 --- a/src/core/skill/skillUtils.ts +++ b/src/core/skill/skillUtils.ts @@ -43,6 +43,17 @@ export const validateSkillName = (name: string): string => { return kebabName.substring(0, SKILL_NAME_MAX_LENGTH); }; +/** + * Converts a string to Title Case. + * Handles kebab-case, snake_case, and other separators. + */ +const toTitleCase = (str: string): string => { + return str + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +}; + /** * Generates a human-readable project name from root directories. * Uses the first directory's basename, converted to Title Case. @@ -50,12 +61,7 @@ export const validateSkillName = (name: string): string => { 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(); + return toTitleCase(dirName); }; /** @@ -74,24 +80,24 @@ export const generateSkillDescription = (_skillName: string, projectName: string */ export const generateProjectNameFromUrl = (remoteUrl: string): string => { const repoName = extractRepoName(remoteUrl); - - // Convert kebab-case or snake_case to Title Case - return repoName - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()) - .trim(); + return toTitleCase(repoName); }; /** * Extracts repository name from a URL or shorthand format. * Examples: * - https://github.com/yamadashy/repomix → repomix + * - 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$/, ''); + // Clean URL: trim, remove query/fragment, trailing slashes, and .git suffix + const cleanUrl = url + .trim() + .replace(/[?#].*$/, '') // Remove query string and fragment + .replace(/\/+$/, '') // Remove trailing slashes + .replace(/\.git$/, ''); // Remove .git suffix // Try to match the last path segment const match = cleanUrl.match(/\/([^/]+)$/); diff --git a/tests/core/skill/packSkill.test.ts b/tests/core/skill/packSkill.test.ts index ed746a738..8d68a4c15 100644 --- a/tests/core/skill/packSkill.test.ts +++ b/tests/core/skill/packSkill.test.ts @@ -253,7 +253,7 @@ describe('packSkill', () => { const result = generateSkillMdFromReferences(referencesResult, 500); expect(result.skillMd).toContain('https://github.com/vitejs/vite'); - expect(result.skillMd).toContain('from [https://github.com/vitejs/vite]'); + expect(result.skillMd).toContain('from [Test Project](https://github.com/vitejs/vite)'); }); test('should not include source URL when not provided', () => { diff --git a/tests/core/skill/skillUtils.test.ts b/tests/core/skill/skillUtils.test.ts index 81f9261d8..ed62ee365 100644 --- a/tests/core/skill/skillUtils.test.ts +++ b/tests/core/skill/skillUtils.test.ts @@ -111,6 +111,26 @@ describe('skillUtils', () => { test('should handle .git suffix', () => { expect(generateProjectNameFromUrl('https://github.com/vitejs/vite.git')).toBe('Vite'); }); + + test('should handle trailing slash', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite/')).toBe('Vite'); + }); + + test('should handle multiple trailing slashes', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite///')).toBe('Vite'); + }); + + test('should handle query string', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite?tab=readme')).toBe('Vite'); + }); + + test('should handle fragment', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite#installation')).toBe('Vite'); + }); + + test('should handle trailing slash with .git suffix', () => { + expect(generateProjectNameFromUrl('https://github.com/vitejs/vite.git/')).toBe('Vite'); + }); }); describe('generateProjectName', () => { From 670e839578e024313e62855882dafea1a2e913ec Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Fri, 12 Dec 2025 21:55:39 +0900 Subject: [PATCH 5/5] fix(skill): Fix ReDoS vulnerability in extractRepoName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace regex patterns that could cause polynomial time complexity with safe string methods: - /[?#].*$/ → indexOf + slice - /\/+$/ → iterative trimTrailingSlashes helper This addresses GitHub Advanced Security warnings about potential ReDoS attacks with maliciously crafted URLs containing many repetitions of '#' or '/'. --- src/core/skill/skillUtils.ts | 49 +++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/core/skill/skillUtils.ts b/src/core/skill/skillUtils.ts index cb7754809..2e12d4063 100644 --- a/src/core/skill/skillUtils.ts +++ b/src/core/skill/skillUtils.ts @@ -83,6 +83,18 @@ export const generateProjectNameFromUrl = (remoteUrl: string): string => { return toTitleCase(repoName); }; +/** + * Removes trailing slashes from a string. + * Uses iterative approach to avoid ReDoS with /\/+$/ regex. + */ +const trimTrailingSlashes = (str: string): string => { + let end = str.length; + while (end > 0 && str[end - 1] === '/') { + end--; + } + return str.slice(0, end); +}; + /** * Extracts repository name from a URL or shorthand format. * Examples: @@ -93,22 +105,35 @@ export const generateProjectNameFromUrl = (remoteUrl: string): string => { */ export const extractRepoName = (url: string): string => { // Clean URL: trim, remove query/fragment, trailing slashes, and .git suffix - const cleanUrl = url - .trim() - .replace(/[?#].*$/, '') // Remove query string and fragment - .replace(/\/+$/, '') // Remove trailing slashes - .replace(/\.git$/, ''); // Remove .git suffix + // Using string methods instead of regex to avoid ReDoS vulnerabilities + let cleanUrl = url.trim(); + + // Remove query string and fragment (find first ? or #) + const queryIndex = cleanUrl.indexOf('?'); + const hashIndex = cleanUrl.indexOf('#'); + if (queryIndex !== -1 || hashIndex !== -1) { + const cutIndex = queryIndex === -1 ? hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex); + cleanUrl = cleanUrl.slice(0, cutIndex); + } + + // Remove trailing slashes + cleanUrl = trimTrailingSlashes(cleanUrl); + + // Remove .git suffix + if (cleanUrl.endsWith('.git')) { + cleanUrl = cleanUrl.slice(0, -4); + } // Try to match the last path segment - const match = cleanUrl.match(/\/([^/]+)$/); - if (match) { - return match[1]; + const lastSlashIndex = cleanUrl.lastIndexOf('/'); + if (lastSlashIndex !== -1 && lastSlashIndex < cleanUrl.length - 1) { + return cleanUrl.slice(lastSlashIndex + 1); } - // For shorthand format like "user/repo" - const shorthandMatch = cleanUrl.match(/^[^/]+\/([^/]+)$/); - if (shorthandMatch) { - return shorthandMatch[1]; + // For shorthand format like "user/repo" (no leading slash, has one slash) + const slashIndex = cleanUrl.indexOf('/'); + if (slashIndex !== -1 && slashIndex < cleanUrl.length - 1) { + return cleanUrl.slice(slashIndex + 1); } return 'unknown';