Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,29 +214,31 @@ export const cleanupTempDirectory = async (directory: string): Promise<void> =>
};

export const copySkillOutputToCurrentDirectory = async (sourceDir: string, targetDir: string): Promise<void> => {
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).`,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/actions/workers/defaultActionWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/core/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const defaultDeps = {
export interface PackOptions {
skillName?: string;
skillDir?: string;
skillProjectName?: string;
skillSourceUrl?: string;
}

export const pack = async (
Expand Down
28 changes: 22 additions & 6 deletions src/core/skill/packSkill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface SkillReferencesResult {
totalLines: number;
statisticsSection: string;
hasTechStack: boolean;
sourceUrl?: string;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -135,6 +138,7 @@ export const generateSkillReferences = async (
totalLines: statistics.totalLines,
statisticsSection,
hasTechStack: techStack !== null,
sourceUrl: skillSourceUrl,
};
};

Expand All @@ -154,6 +158,7 @@ export const generateSkillMdFromReferences = (
totalLines: referencesResult.totalLines,
totalTokens,
hasTechStack: referencesResult.hasTechStack,
sourceUrl: referencesResult.sourceUrl,
});

return {
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/core/skill/skillStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface SkillRenderContext {
totalLines: number;
totalTokens: number;
hasTechStack: boolean;
sourceUrl?: string;
}

/**
Expand Down Expand Up @@ -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: <path>\`) |
{{#if hasTechStack}}
| \`references/tech-stack.md\` | Languages, frameworks, and dependencies |
{{/if}}
| \`references/summary.md\` | Purpose, format explanation, and statistics |

## How to Use

Expand Down Expand Up @@ -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 [{{{projectName}}}]({{{sourceUrl}}}){{/if}}
`;
};

Expand Down
75 changes: 60 additions & 15 deletions src/core/skill/skillUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,25 @@ 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.
*/
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);
};

/**
Expand All @@ -68,27 +74,66 @@ 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);
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);
};
Comment thread
yamadashy marked this conversation as resolved.
Comment thread
yamadashy marked this conversation as resolved.

/**
* 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
// 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';
Expand Down
Loading
Loading