Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ParsedArgs {
['prompt-pack-mode']?: string;
['no-skill-install']?: boolean;
['skill-update']?: boolean;
prefix?: string;
['allow-collision']?: boolean;
['shell-env']?: boolean;
['no-shell-env']?: boolean;
['tweakcc-stdio']?: string;
Expand Down
47 changes: 46 additions & 1 deletion src/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,42 @@ interface CreateParams {
requiresCredential: boolean;
shouldPromptApiKey: boolean;
hasZaiEnv: boolean;
allowCollision: boolean;
}

const buildDefaultName = (provider: ProviderTemplate, providerKey: string, opts: ParsedArgs): string => {
const explicitName = typeof opts.name === 'string' ? opts.name.trim() : '';
if (explicitName) return explicitName;
const prefix = typeof opts.prefix === 'string' ? opts.prefix.trim() : '';
if (prefix) return `${prefix}${providerKey}`;
return provider.defaultVariantName || providerKey;
};

const assertNoCommandCollision = (
name: string,
binDir: string,
provider: ProviderTemplate,
providerKey: string,
allowCollision: boolean
): void => {
const collision = core.detectCommandCollision(name, binDir);
if (!collision.hasCollision || allowCollision) return;

const suggested = provider.defaultVariantName || `cc${providerKey}`;
const reasons: string[] = [];
if (collision.wrapperExists) {
reasons.push(`wrapper already exists at ${collision.wrapperPath}`);
}
if (collision.pathConflicts && collision.resolvedCommandPath) {
reasons.push(`'${name}' already resolves to ${collision.resolvedCommandPath}`);
}

throw new Error(
`Command name collision for "${name}": ${reasons.join('; ')}. ` +
`Use --name <unique-name> (suggested: "${suggested}") or --allow-collision to bypass.`
);
};
Comment on lines +49 to +72
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collision checking runs before variant-name validation, so an invalid --name can surface a collision error (or trigger odd which/where behavior) instead of the intended “invalid variant name” error from the core builder. Consider validating name (via the same assertValidVariantName used in core) before calling assertNoCommandCollision(), so users get the correct error and collision checks only run on valid command names.

Copilot uses AI. Check for mistakes.

/**
* Prepare common parameters for create command
*/
Expand All @@ -55,7 +89,7 @@ async function prepareCreateParams(opts: ParsedArgs): Promise<CreateParams> {
throw new Error(`Unknown provider: ${providerKey}`);
}

const name = (opts.name as string) || provider.defaultVariantName || providerKey;
const name = buildDefaultName(provider, providerKey, opts);
const baseUrl = (opts['base-url'] as string) || provider.baseUrl;
const envZaiKey = providerKey === 'zai' ? process.env.Z_AI_API_KEY : undefined;
const envAnthropicKey = providerKey === 'zai' ? process.env.ANTHROPIC_API_KEY : undefined;
Expand All @@ -79,6 +113,7 @@ async function prepareCreateParams(opts: ParsedArgs): Promise<CreateParams> {
// Don't prompt for API key if credential is optional (mirror, ccrouter)
const shouldPromptApiKey =
!provider.credentialOptional && !opts.yes && !hasCredentialFlag && (providerKey === 'zai' ? !hasZaiEnv : !apiKey);
const allowCollision = Boolean(opts['allow-collision']);

return {
provider,
Expand All @@ -94,6 +129,7 @@ async function prepareCreateParams(opts: ParsedArgs): Promise<CreateParams> {
requiresCredential,
shouldPromptApiKey,
hasZaiEnv,
allowCollision,
};
}

Expand Down Expand Up @@ -147,6 +183,7 @@ async function handleQuickMode(opts: ParsedArgs, params: CreateParams): Promise<
skillInstall,
shellEnv,
skillUpdate,
allowCollision: params.allowCollision,
modelOverrides: resolvedModelOverrides,
tweakccStdio: 'pipe',
});
Expand Down Expand Up @@ -210,6 +247,8 @@ async function handleInteractiveMode(opts: ParsedArgs, params: CreateParams): Pr
}
}

assertNoCommandCollision(nextName, nextBin, params.provider, params.providerKey, params.allowCollision);

const result = await core.createVariantAsync({
name: nextName,
providerKey: params.providerKey,
Expand All @@ -225,6 +264,7 @@ async function handleInteractiveMode(opts: ParsedArgs, params: CreateParams): Pr
skillInstall,
shellEnv,
skillUpdate,
allowCollision: params.allowCollision,
modelOverrides: resolvedModelOverrides,
tweakccStdio: 'pipe',
});
Expand Down Expand Up @@ -270,6 +310,7 @@ async function handleNonInteractiveMode(opts: ParsedArgs, params: CreateParams):
skillInstall,
shellEnv,
skillUpdate,
allowCollision: params.allowCollision,
modelOverrides: resolvedModelOverrides,
tweakccStdio: 'pipe',
});
Expand All @@ -290,6 +331,10 @@ async function handleNonInteractiveMode(opts: ParsedArgs, params: CreateParams):
export async function runCreateCommand({ opts, quickMode }: CreateCommandOptions): Promise<void> {
const params = await prepareCreateParams(opts);

if (quickMode || opts.yes) {
assertNoCommandCollision(params.name, params.binDir, params.provider, params.providerKey, params.allowCollision);
}

if (quickMode) {
await handleQuickMode(opts, params);
} else if (opts.yes) {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ COMMANDS

OPTIONS (create/quick)
--name <name> Variant name (becomes CLI command)
--prefix <value> Prefix default variant name when --name is omitted
--provider <name> Provider: kimi | minimax | zai | openrouter | vercel | ollama | nanogpt | ccrouter | mirror | gatewayz
--api-key <key> Provider API key
--auth-token <token> Alias for --api-key (auth-token providers)
Expand All @@ -52,6 +53,7 @@ OPTIONS (advanced)
--bin-dir <path> Wrapper install dir (default: ${DEFAULT_BIN_DIR})
--no-tweak Skip tweakcc theming
--no-prompt-pack Skip provider prompt pack
--allow-collision Allow wrapper command name collisions (unsafe)
--shell-env Write env vars to shell profile
--verbose Show full tweakcc output during update
--json Machine-readable output (list/doctor)
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {

export { DEFAULT_ROOT, DEFAULT_BIN_DIR, DEFAULT_CLAUDE_VERSION, DEFAULT_CLAUDE_NATIVE_CACHE_DIR };
export { expandTilde } from './paths.js';
export { detectCommandCollision } from './paths.js';

export const createVariant = (params: CreateVariantParams): CreateVariantResult => {
return new VariantBuilder(false).build(params);
Expand Down
51 changes: 51 additions & 0 deletions src/core/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -42,3 +43,53 @@ export const commandExists = (cmd: string): boolean => {
});
return result.status === 0 && result.stdout.trim().length > 0;
};

export interface CommandCollisionCheck {
wrapperPath: string;
wrapperExists: boolean;
binDirOnPath: boolean;
resolvedCommandPath: string | null;
pathConflicts: boolean;
hasCollision: boolean;
}

const normalizeFsPath = (input: string): string => {
const normalized = path.normalize(input);
return isWindows ? normalized.toLowerCase() : normalized;
};

export const resolveCommandPath = (cmd: string): string | null => {
const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
encoding: 'utf8',
});
if (result.status !== 0 || !result.stdout) return null;
const firstLine = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
return firstLine || null;
Comment on lines +61 to +70
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveCommandPath() will treat names starting with - as options to which/where (e.g., --name=-h), which can yield incorrect results or unexpected output. Consider guarding against option-like commands (e.g., early-return null when cmd starts with -) or passing an argument terminator where supported, so collision checks can’t be influenced by flag parsing.

Copilot uses AI. Check for mistakes.
};

export const detectCommandCollision = (name: string, binDir: string): CommandCollisionCheck => {
const resolvedBin = expandTilde(binDir) ?? binDir;
const wrapperPath = getWrapperPath(resolvedBin, name);
const wrapperExists = fs.existsSync(wrapperPath);
const pathEntries = (process.env.PATH || '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const normalizedResolvedBin = normalizeFsPath(resolvedBin);
const binDirOnPath = pathEntries.some((entry) => normalizeFsPath(entry) === normalizedResolvedBin);
const resolvedCommandPath = resolveCommandPath(name);
const pathConflicts = Boolean(
binDirOnPath && resolvedCommandPath && normalizeFsPath(resolvedCommandPath) !== normalizeFsPath(wrapperPath)
);
Comment on lines +83 to +86
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detectCommandCollision() always calls resolveCommandPath() (spawns which/where) even when binDir is not on $PATH, but the result is only used when binDirOnPath is true. To match the PR’s “only when binDir is on PATH” behavior and avoid extra process spawns, compute resolvedCommandPath lazily only when binDirOnPath is true.

Suggested change
const resolvedCommandPath = resolveCommandPath(name);
const pathConflicts = Boolean(
binDirOnPath && resolvedCommandPath && normalizeFsPath(resolvedCommandPath) !== normalizeFsPath(wrapperPath)
);
let resolvedCommandPath: string | null = null;
let pathConflicts = false;
if (binDirOnPath) {
resolvedCommandPath = resolveCommandPath(name);
pathConflicts = Boolean(
resolvedCommandPath && normalizeFsPath(resolvedCommandPath) !== normalizeFsPath(wrapperPath)
);
}

Copilot uses AI. Check for mistakes.
return {
wrapperPath,
wrapperExists,
binDirOnPath,
resolvedCommandPath,
pathConflicts,
hasCollision: wrapperExists || pathConflicts,
};
};
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface CreateVariantParams {
shellEnv?: boolean;
skillUpdate?: boolean;
tweakccStdio?: 'pipe' | 'inherit';
/** Allow wrapper command name collisions (unsafe). */
allowCollision?: boolean;
/** Callback for progress updates during installation */
onProgress?: ProgressCallback;
}
Expand Down
19 changes: 18 additions & 1 deletion src/core/variant-builder/VariantBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import path from 'node:path';
import { getProvider, type ProviderTemplate } from '../../providers/index.js';
import { DEFAULT_BIN_DIR, DEFAULT_CLAUDE_VERSION, DEFAULT_ROOT } from '../constants.js';
import { assertValidVariantName, expandTilde, getWrapperPath } from '../paths.js';
import { assertValidVariantName, detectCommandCollision, expandTilde, getWrapperPath } from '../paths.js';
import type { CreateVariantParams, CreateVariantResult } from '../types.js';
import type { BuildContext, BuildPaths, BuildPreferences, BuildState, BuildStep, ReportFn } from './types.js';

Expand Down Expand Up @@ -83,6 +83,23 @@ export class VariantBuilder {
const wrapperPath = getWrapperPath(resolvedBin, params.name);
const nativeDir = path.join(variantDir, 'native');

if (!params.allowCollision) {
const collision = detectCommandCollision(params.name, resolvedBin);
if (collision.hasCollision) {
const reasons: string[] = [];
if (collision.wrapperExists) {
reasons.push(`wrapper already exists at ${collision.wrapperPath}`);
}
if (collision.pathConflicts && collision.resolvedCommandPath) {
reasons.push(`'${params.name}' already resolves to ${collision.resolvedCommandPath}`);
}
throw new Error(
`Command name collision for "${params.name}": ${reasons.join('; ')}. ` +
'Use a unique variant name or set allowCollision to true.'
);
}
}

const paths: BuildPaths = {
resolvedRoot,
resolvedBin,
Expand Down
17 changes: 17 additions & 0 deletions src/tui/hooks/useVariantCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react';
import path from 'node:path';
import type { CoreModule } from '../app.js';
import type { CreateVariantParams, CompletionResult, ModelOverrides } from './types.js';
import { detectCommandCollision } from '../../core/paths.js';

export interface UseVariantCreateOptions {
screen: string;
Expand Down Expand Up @@ -91,6 +92,22 @@ export function useVariantCreate(options: UseVariantCreateOptions): void {

const runCreate = async () => {
try {
const collision = detectCommandCollision(params.name, params.binDir);
if (collision.hasCollision) {
const suggested = params.provider?.defaultVariantName || `cc${params.providerKey}`;
const reasons: string[] = [];
if (collision.wrapperExists) {
reasons.push(`wrapper exists at ${collision.wrapperPath}`);
}
if (collision.pathConflicts && collision.resolvedCommandPath) {
reasons.push(`'${params.name}' resolves to ${collision.resolvedCommandPath}`);
}
throw new Error(
`Command name collision for "${params.name}": ${reasons.join('; ')}. ` +
`Choose another name (suggested: "${suggested}").`
);
}
Comment on lines +95 to +109
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TUI now performs collision detection before calling into core, but this also happens before core’s assertValidVariantName() validation. If the user enters an invalid name (e.g., starting with - or containing /), the error shown may be a collision/resolve error rather than the “invalid variant name” message. Consider validating the name before running detectCommandCollision() so the failure mode is deterministic and the error message is accurate.

Copilot uses AI. Check for mistakes.

setProgressLines(() => []);
const createParams = {
name: params.name,
Expand Down
7 changes: 7 additions & 0 deletions test/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ test('parseArgs handles key value arguments', () => {
assert.equal(result.name, 'my-variant');
});

test('parseArgs handles --prefix and --allow-collision flags', () => {
const result = parseArgs(['--provider', 'kimi', '--prefix', 'cc', '--allow-collision']);
assert.equal(result.provider, 'kimi');
assert.equal(result.prefix, 'cc');
assert.equal(result['allow-collision'], true);
});

test('parseArgs handles boolean flags with trailing value', () => {
// Parser treats consecutive flags as key-value pairs when no = is used
// This tests that the parser correctly handles the case where a flag
Expand Down
57 changes: 57 additions & 0 deletions test/core/paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';

import { cleanup, makeTempDir } from '../helpers/index.js';
import { detectCommandCollision, getWrapperPath, resolveCommandPath } from '../../src/core/paths.js';

test('resolveCommandPath returns null for unknown command', () => {
const resolved = resolveCommandPath('cc_mirror_command_that_should_not_exist_12345');
assert.equal(resolved, null);
});

test('detectCommandCollision reports wrapper collisions', () => {
const binDir = makeTempDir();
try {
const wrapperPath = getWrapperPath(binDir, 'samplecmd');
fs.writeFileSync(wrapperPath, '#!/usr/bin/env bash\necho hello\n');
const result = detectCommandCollision('samplecmd', binDir);
assert.equal(result.wrapperExists, true);
assert.equal(result.hasCollision, true);
} finally {
cleanup(binDir);
}
});

test('detectCommandCollision reports path conflicts for existing system commands', () => {
const resolvedNode = resolveCommandPath('node');
if (!resolvedNode) return;

const binDir = makeTempDir();
try {
const result = detectCommandCollision('node', binDir);
assert.equal(result.binDirOnPath, false);
assert.equal(result.pathConflicts, false);
assert.equal(result.hasCollision, false);
} finally {
cleanup(binDir);
}
});

test('detectCommandCollision marks path conflict when bin dir is on PATH', () => {
const resolvedNode = resolveCommandPath('node');
if (!resolvedNode) return;

const binDir = makeTempDir();
const previousPath = process.env.PATH;
try {
process.env.PATH = `${binDir}${process.platform === 'win32' ? ';' : ':'}${previousPath || ''}`;
const result = detectCommandCollision('node', binDir);
assert.equal(result.binDirOnPath, true);
assert.equal(result.pathConflicts, true);
assert.equal(result.hasCollision, true);
} finally {
process.env.PATH = previousPath;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previousPath can be undefined in some environments; assigning it back via process.env.PATH = previousPath will coerce to the string "undefined" and can break subsequent tests. In the finally, restore with delete process.env.PATH when previousPath is undefined, otherwise set it back to the prior string value.

Suggested change
process.env.PATH = previousPath;
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}

Copilot uses AI. Check for mistakes.
cleanup(binDir);
}
});
Loading