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
23 changes: 20 additions & 3 deletions src/cli/actions/defaultAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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 { promptSkillLocation, resolveAndPrepareSkillDir } from '../prompts/skillPrompts.js';
import type { CliOptions } from '../types.js';
import { runMigrationAction } from './migrationAction.js';
import type {
Expand Down Expand Up @@ -55,6 +55,19 @@ export const runDefaultAction = async (
// Validate conflicting options
validateConflictingOptions(config);

// Validate --skill-output and --force require --skill-generate
if (cliOptions.skillOutput && config.skillGenerate === undefined) {
throw new RepomixError('--skill-output can only be used with --skill-generate');
}
if (cliOptions.force && config.skillGenerate === undefined) {
throw new RepomixError('--force can only be used with --skill-generate');
}
Comment thread
yamadashy marked this conversation as resolved.

// Validate --skill-output is not empty or whitespace only
if (cliOptions.skillOutput !== undefined && !cliOptions.skillOutput.trim()) {
throw new RepomixError('--skill-output path cannot be empty');
}

// Validate skill generation options and prompt for location
if (config.skillGenerate !== undefined) {
// Resolve skill name: use pre-computed name (from remoteAction) or generate from directory
Expand All @@ -63,8 +76,12 @@ export const runDefaultAction = async (
? config.skillGenerate
: generateDefaultSkillName(directories.map((d) => path.resolve(cwd, d)));

// Prompt for skill location if not already set (from remoteAction)
if (!cliOptions.skillDir) {
// Determine skill directory
if (cliOptions.skillOutput && !cliOptions.skillDir) {
// Non-interactive mode: use provided path directly
cliOptions.skillDir = await resolveAndPrepareSkillDir(cliOptions.skillOutput, cwd, cliOptions.force ?? false);
} else if (!cliOptions.skillDir) {
// Interactive mode: prompt for skill location
const promptResult = await promptSkillLocation(cliOptions.skillName, cwd);
cliOptions.skillDir = promptResult.skillDir;
}
Expand Down
69 changes: 18 additions & 51 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { generateDefaultSkillNameFromUrl, generateProjectNameFromUrl } from '../
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 { promptSkillLocation, resolveAndPrepareSkillDir } from '../prompts/skillPrompts.js';
import type { CliOptions } from '../types.js';
import { type DefaultActionRunnerResult, runDefaultAction } from './defaultAction.js';

Expand Down Expand Up @@ -93,7 +93,6 @@ 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;
let skillProjectName: string | undefined;
if (cliOptions.skillGenerate !== undefined) {
skillName =
Expand All @@ -104,9 +103,18 @@ export const runRemoteAction = async (
// 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;
if (cliOptions.skillOutput) {
// Validate --skill-output is not empty or whitespace only
if (!cliOptions.skillOutput.trim()) {
throw new RepomixError('--skill-output path cannot be empty');
}
// Non-interactive mode: use provided path directly
skillDir = await resolveAndPrepareSkillDir(cliOptions.skillOutput, process.cwd(), cliOptions.force ?? false);
} else {
// Interactive mode: prompt for skill location
const promptResult = await promptSkillLocation(skillName, process.cwd());
skillDir = promptResult.skillDir;
}
}

// Run the default action on the downloaded/cloned repository
Expand All @@ -115,17 +123,12 @@ export const runRemoteAction = async (
const optionsWithSkill = { ...cliOptions, skillName, skillDir, skillProjectName, skillSourceUrl };
result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithSkill);

// Copy output to current directory
// Copy output to current directory (only for non-skill generation)
// 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) {
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);
}
// For skill generation, the skill is already written directly to the target directory
// (either via --skill-output path or via promptSkillLocation which uses process.cwd())
if (!cliOptions.stdout && result.config.skillGenerate === undefined) {
await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath);
}

logger.trace(`Repository obtained via ${downloadMethod} method`);
Expand Down Expand Up @@ -213,42 +216,6 @@ 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> => {
// 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/skills directory exists
await fs.access(sourceSkillsDir);
} catch {
// No skill output was generated
logger.trace('No .claude/skills directory found in source, skipping skill output copy');
return;
}

try {
logger.trace(`Copying skill output from: ${sourceSkillsDir} to: ${targetSkillsDir}`);

// 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 ${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).`,
);
}

throw new RepomixError(`Failed to copy skill output: ${(error as Error).message}`);
}
};

export const copyOutputToCurrentDirectory = async (
sourceDir: string,
targetDir: string,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ export const run = async () => {
'--skill-generate [name]',
'Generate Claude Agent Skills format output to .claude/skills/<name>/ directory (name auto-generated if omitted)',
)
.option('--skill-output <path>', 'Specify skill output directory path directly (skips location prompt)')
.option('-f, --force', 'Skip all confirmation prompts (currently: skill directory overwrite)')
.action(commanderActionEndpoint);

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

export type SkillLocation = 'personal' | 'project';
Expand Down Expand Up @@ -90,3 +90,56 @@ export const promptSkillLocation = async (
skillDir,
};
};

/**
* Prepare skill directory for non-interactive mode.
* Handles force overwrite by removing existing directory.
*/
export const prepareSkillDir = async (
skillDir: string,
force: boolean,
deps = {
access: fs.access,
rm: fs.rm,
stat: fs.stat,
},
): Promise<void> => {
try {
await deps.access(skillDir);
// Path exists - check if it's a directory
const stats = await deps.stat(skillDir);
if (!stats.isDirectory()) {
throw new RepomixError(`Skill output path exists but is not a directory: ${skillDir}`);
}
// Directory exists
if (force) {
await deps.rm(skillDir, { recursive: true, force: true });
} else {
throw new RepomixError(`Skill directory already exists: ${skillDir}. Use --force to overwrite.`);
}
} catch (error) {
// Re-throw if it's not a "file not found" error
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
// Directory doesn't exist - good to go
}
};
Comment thread
yamadashy marked this conversation as resolved.

/**
* Resolve skill output path and prepare directory for non-interactive mode.
* Returns the resolved skill directory path.
*/
export const resolveAndPrepareSkillDir = async (skillOutput: string, cwd: string, force: boolean): Promise<string> => {
const skillDir = path.isAbsolute(skillOutput) ? skillOutput : path.resolve(cwd, skillOutput);
await prepareSkillDir(skillDir, force);
return skillDir;
};

/**
* Determine skill location type based on the skill directory path.
*/
export const getSkillLocation = (skillDir: string): SkillLocation => {
const personalSkillsBase = getSkillBaseDir('', 'personal');
return skillDir.startsWith(personalSkillsBase) ? 'personal' : 'project';
};
2 changes: 2 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface CliOptions extends OptionValues {
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)
skillOutput?: string; // Output path for skill (skips location prompt)
force?: boolean; // Skip all confirmation prompts

// Other Options
topFilesLen?: number;
Expand Down
74 changes: 1 addition & 73 deletions tests/cli/actions/remoteAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ 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,
copySkillOutputToCurrentDirectory,
runRemoteAction,
} from '../../../src/cli/actions/remoteAction.js';
import { copyOutputToCurrentDirectory, runRemoteAction } from '../../../src/cli/actions/remoteAction.js';
import { createMockConfig } from '../../testing/testUtils.js';

vi.mock('node:fs/promises', async (importOriginal) => {
Expand Down Expand Up @@ -291,72 +287,4 @@ 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',
);
});
});
});
Loading
Loading