Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
71515c7
feat(cli): Add --generate-skill option for Claude Agent Skills output
yamadashy Dec 6, 2025
df5647c
feat(skill): Enhance skill output with split files and auto-naming
yamadashy Dec 7, 2025
f854dd4
refactor(skill): Improve skill output generation and add tests
yamadashy Dec 7, 2025
e137b56
feat(skill): Add interactive skill location selection
yamadashy Dec 7, 2025
46094b8
fix(cli): Show correct skill output path in summary
yamadashy Dec 7, 2025
c5fb07c
fix(cli): Show relative path for skill output when under cwd
yamadashy Dec 7, 2025
7b82617
refactor(cli): Apply relative path display to regular output
yamadashy Dec 7, 2025
686cebe
style(cli): Improve skill overwrite confirmation message format
yamadashy Dec 7, 2025
6f67173
refactor(cli): Extract getDisplayPath helper function
yamadashy Dec 7, 2025
c57dd55
feat(skill): Add line counts to directory structure in skill output
yamadashy Dec 7, 2025
88a20b9
docs(skill): Update SKILL.md to explain grep-friendly features
yamadashy Dec 7, 2025
e40c5f3
refactor(skill): Remove git diffs and logs from skill output
yamadashy Dec 7, 2025
9916cec
docs(skill): Improve SKILL.md template for better usability
yamadashy Dec 7, 2025
8aa10c5
docs(skill): Improve SKILL.md template with overview and use cases
yamadashy Dec 7, 2025
2a4209d
feat(skill): Add tech-stack detection and file statistics to skill ou…
yamadashy Dec 7, 2025
b4f25a0
refactor(skill): Pass skillName directly instead of remoteUrl in config
yamadashy Dec 7, 2025
975ffe6
refactor(cli): Move skillPrompts.ts to cli/prompts directory
yamadashy Dec 7, 2025
84c14cc
feat(skill): Enhance tech-stack detection and improve skill output
yamadashy Dec 8, 2025
1987d1d
refactor(skill): Remove unused git diffs/logs section generators
yamadashy Dec 9, 2025
54a1391
refactor(cli): Use cliOptions directly for skillName and skillDir
yamadashy Dec 9, 2025
0fa885c
refactor(skill): Move skill-related code to core/skill/ directory
yamadashy Dec 10, 2025
e52a430
refactor(skill): Remove unnecessary re-exports from outputGenerate.ts
yamadashy Dec 10, 2025
6002e06
fix(skill): Remove duplicate calculateMetrics call and fix MCP tool
yamadashy Dec 11, 2025
d6d82b6
fix(security): Add path traversal protection for skill generation
yamadashy Dec 11, 2025
6a397a0
test(skill): Add tests for skill generation functionality
yamadashy Dec 11, 2025
d1f6498
chore(typos): Add typos configuration to allow 'styl' extension
yamadashy Dec 11, 2025
5688667
fix(typos): Rename .typos.toml to _typos.toml
yamadashy Dec 11, 2025
f8f476f
fix(typos): Add 'styl' to extend-words in existing typos.toml
yamadashy Dec 11, 2025
59a42e6
refactor(skill): Improve code quality based on review feedback
yamadashy Dec 11, 2025
7e5a431
fix(skill): Address PR review feedback
yamadashy Dec 11, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ repomix-output.json
# repomix runner
.repomix/

# repomix references
.claude/skills/repomix-reference-*/

# Agent
/.mcp.json
.agents/local/
Expand Down
36 changes: 35 additions & 1 deletion src/cli/actions/defaultAction.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import { loadFileConfig, mergeConfigs } from '../../config/configLoad.js';
import {
type RepomixConfigCli,
Expand All @@ -8,11 +9,13 @@ import {
} from '../../config/configSchema.js';
import { readFilePathsFromStdin } from '../../core/file/fileStdin.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';
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 type {
Expand Down Expand Up @@ -49,6 +52,32 @@ export const runDefaultAction = async (
const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig);
logger.trace('Merged config:', config);

// Validate skill generation options and prompt for location
if (config.skillGenerate !== undefined) {
if (config.output.stdout) {
throw new RepomixError(
'--skill-generate cannot be used with --stdout. Skill output requires writing to filesystem.',
);
}
if (config.output.copyToClipboard) {
throw new RepomixError(
'--skill-generate cannot be used with --copy. Skill output is a directory and cannot be copied to clipboard.',
);
}

// Resolve skill name: use pre-computed name (from remoteAction) or generate from directory
cliOptions.skillName ??=
typeof config.skillGenerate === 'string'
? config.skillGenerate
: 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;
}
}

// Handle stdin processing in main process (before worker creation)
// This is necessary because child_process workers don't inherit stdin
let stdinFilePaths: string[] | undefined;
Expand Down Expand Up @@ -90,7 +119,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, cliOptions);

return {
packResult: result.packResult,
Expand Down Expand Up @@ -287,6 +316,11 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => {
};
}

// Skill generation
if (options.skillGenerate !== undefined) {
cliConfig.skillGenerate = options.skillGenerate;
}

try {
return repomixConfigCliSchema.parse(cliConfig);
} catch (error) {
Expand Down
69 changes: 64 additions & 5 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ 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 { 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';

Expand Down Expand Up @@ -88,14 +90,37 @@ export const runRemoteAction = async (
downloadMethod = 'git';
}

// 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) {
skillName =
typeof cliOptions.skillGenerate === 'string'
? cliOptions.skillGenerate
: generateDefaultSkillNameFromUrl(repoUrl);

const promptResult = await promptSkillLocation(skillName, process.cwd());
skillDir = promptResult.skillDir;
skillLocation = promptResult.location;
}

// Run the default action on the downloaded/cloned repository
result = await deps.runDefaultAction([tempDirPath], tempDirPath, cliOptions);
// Pass the pre-computed skill name and directory
const optionsWithSkill = { ...cliOptions, skillName, skillDir };
result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithSkill);

// 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 with project location, copy the skill directory
// For personal location, skill is already written to ~/.claude/skills/
if (!cliOptions.stdout) {
await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath);
if (result.config.skillGenerate !== undefined && skillLocation === 'project') {
// Copy skill directory to current directory (only for project skills)
await copySkillOutputToCurrentDirectory(tempDirPath, process.cwd());
} else if (result.config.skillGenerate === undefined) {
await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath);
}
}

logger.trace(`Repository obtained via ${downloadMethod} method`);
Expand Down Expand Up @@ -183,6 +208,40 @@ export const cleanupTempDirectory = async (directory: string): Promise<void> =>
await fs.rm(directory, { recursive: true, force: true });
};

export const copySkillOutputToCurrentDirectory = async (sourceDir: string, targetDir: string): Promise<void> => {
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,
Expand Down
17 changes: 14 additions & 3 deletions src/cli/actions/workers/defaultActionWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ async function defaultActionWorker(
let packResult: PackResult;

try {
const { skillName, skillDir } = cliOptions;
const packOptions = { skillName, skillDir };

if (stdinFilePaths) {
// Handle stdin processing with file paths from main process
// File paths were already read from stdin in the main process
Expand All @@ -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!');
Expand Down
38 changes: 34 additions & 4 deletions src/cli/cliReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,26 @@ 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;
}

/**
* 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) {
Expand Down Expand Up @@ -40,13 +56,18 @@ export const reportResults = (cwd: string, packResult: PackResult, config: Repom
reportSkippedFiles(cwd, packResult.skippedFiles);
logger.log('');

reportSummary(packResult, config);
reportSummary(cwd, packResult, config, options);
logger.log('');

reportCompletion();
};

export const reportSummary = (packResult: PackResult, config: RepomixConfigMerged) => {
export const reportSummary = (
cwd: string,
packResult: PackResult,
config: RepomixConfigMerged,
options: ReportOptions = {},
) => {
let securityCheckMessage = '';
if (config.security.enableSecurityCheck) {
if (packResult.suspiciousFilesResults.length > 0) {
Expand All @@ -65,7 +86,16 @@ 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.skillGenerate !== undefined && 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 = getDisplayPath(outputPath, cwd);
logger.log(`${pc.white(' Output:')} ${pc.white(displayPath)}`);
}
logger.log(`${pc.white(' Security:')} ${pc.white(securityCheckMessage)}`);

if (config.output.git?.includeDiffs) {
Expand Down
6 changes: 6 additions & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'--skill-generate [name]',
'Generate Claude Agent Skills format output to .claude/skills/<name>/ directory (name auto-generated if omitted)',
)
.action(commanderActionEndpoint);

// Custom error handling function
Expand Down
92 changes: 92 additions & 0 deletions src/cli/prompts/skillPrompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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';
import { OperationCancelledError } from '../../shared/errorHandle.js';
import { getDisplayPath } from '../cliReport.js';

export type SkillLocation = 'personal' | 'project';

export interface SkillPromptResult {
location: SkillLocation;
skillDir: string;
}

const onCancelOperation = (): never => {
prompts.cancel('Skill generation cancelled.');
throw new OperationCancelledError('Skill generation cancelled');
};

/**
* 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<SkillPromptResult> => {
// 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 displayPath = getDisplayPath(skillDir, cwd);
const overwrite = await deps.confirm({
message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${displayPath}`)}`,
});

if (deps.isCancel(overwrite) || !overwrite) {
onCancelOperation();
}
}

return {
location: location as SkillLocation,
skillDir,
};
};
5 changes: 5 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface CliOptions extends OptionValues {
// MCP
mcp?: boolean;

// 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
topFilesLen?: number;
verbose?: boolean;
Expand Down
Loading
Loading