diff --git a/src/cli/commands/create/__tests__/create.test.ts b/src/cli/commands/create/__tests__/create.test.ts index 972b38533..72ab2c64f 100644 --- a/src/cli/commands/create/__tests__/create.test.ts +++ b/src/cli/commands/create/__tests__/create.test.ts @@ -38,6 +38,18 @@ describe('create command', () => { expect(json.success).toBe(false); expect(json.error.includes('conflicts')).toBeTruthy(); }); + + it('creates project-only scaffold with --project-name and no --name', async () => { + const projectName = `ProjOnly${Date.now()}`; + const result = await runCLI(['create', '--project-name', projectName, '--no-agent', '--json'], testDir); + + expect(result.exitCode, `stderr: ${result.stderr}, stdout: ${result.stdout}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(await exists(join(json.projectPath, 'agentcore'))).toBeTruthy(); + }); }); describe('with agent', () => { @@ -144,6 +156,44 @@ describe('create command', () => { expect(episodic?.namespaces).toEqual(['/episodes/{actorId}/{sessionId}']); expect(episodic?.reflectionNamespaces).toEqual(['/episodes/{actorId}']); }); + + it('uses --project-name for project and --name for agent resource', async () => { + const projectName = `AgentProj${Date.now().toString().slice(-6)}`; + const agentName = `AgentResource${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const result = await runCLI( + [ + 'create', + '--project-name', + projectName, + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--skip-git', + '--skip-install', + '--json', + ], + testDir + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(json.agentName).toBe(agentName); + expect(await exists(join(json.projectPath, 'app', agentName))).toBeTruthy(); + + const projectSpec = JSON.parse(await readFile(join(json.projectPath, 'agentcore/agentcore.json'), 'utf-8')); + expect(projectSpec.name).toBe(projectName); + expect(projectSpec.runtimes[0].name).toBe(agentName); + }); }); describe('--defaults', () => { @@ -167,6 +217,21 @@ describe('create command', () => { expect(result.stdout.includes('would create') || result.stdout.includes('Dry run')).toBeTruthy(); expect(await exists(join(testDir, name)), 'Should not create directory').toBe(false); }); + + it('uses project-name for project paths and name for app paths', async () => { + const projectName = `DryProj${Date.now().toString().slice(-6)}`; + const agentName = `DryAgent${Date.now().toString().slice(-6)}`; + const result = await runCLI( + ['create', '--project-name', projectName, '--name', agentName, '--defaults', '--dry-run', '--json'], + testDir + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(json.wouldCreate).toContain(`${json.projectPath}/app/${agentName}/`); + expect(await exists(join(testDir, projectName)), 'Should not create directory').toBe(false); + }); }); describe('--skip-git', () => { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index e5938a964..8c118ebf5 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -35,6 +35,7 @@ describe('validateCreateOptions', () => { beforeAll(() => { testDir = join(tmpdir(), `create-opts-${randomUUID()}`); mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'ExistingProject'), { recursive: true }); }); afterAll(() => { @@ -59,6 +60,42 @@ describe('validateCreateOptions', () => { expect(result.error).toContain('already exists'); }); + it('validates projectName separately from agent name', () => { + const result = validateCreateOptions( + { + name: `Agent${'A'.repeat(30)}`, + projectName: 'ShortProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('checks folder existence using projectName', () => { + const result = validateCreateOptions( + { + name: 'AgentName', + projectName: 'ExistingProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('ExistingProject'); + }); + + it('allows project-only create with only projectName', () => { + const result = validateCreateOptions({ projectName: 'OnlyProject', agent: false }, testDir); + expect(result.valid).toBe(true); + }); + it('returns valid with --no-agent flag', () => { const result = validateCreateOptions({ name: 'NoAgentProject', agent: false }, testDir); expect(result.valid).toBe(true); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 59126d66f..dbfc215d7 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -111,6 +111,7 @@ type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; export interface CreateWithAgentOptions { name: string; + projectName?: string; cwd: string; type?: 'create' | 'import'; buildType?: BuildType; @@ -139,6 +140,7 @@ export interface CreateWithAgentOptions { export async function createProjectWithAgent(options: CreateWithAgentOptions): Promise { const { name, + projectName: explicitProjectName, cwd, buildType, language, @@ -159,7 +161,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P skipPythonSetup, onProgress, } = options; - const projectRoot = join(cwd, name); + const projectName = explicitProjectName ?? name; + const projectRoot = join(cwd, projectName); const configBaseDir = join(projectRoot, CONFIG_DIR); // Check CLI dependencies first (with language for conditional uv check) @@ -172,7 +175,14 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } // First create the base project (skip dependency check since we already did it) - const projectResult = await createProject({ name, cwd, skipGit, skipInstall, skipDependencyCheck: true, onProgress }); + const projectResult = await createProject({ + name: projectName, + cwd, + skipGit, + skipInstall, + skipDependencyCheck: true, + onProgress, + }); if (!projectResult.success) { // Merge warnings from both checks const allWarnings = [...depWarnings, ...(projectResult.warnings ?? [])]; @@ -243,7 +253,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P if (!isMcp && resolvedModelProvider !== 'Bedrock') { strategy = await credentialPrimitive.resolveCredentialStrategy( - name, + projectName, agentName, resolvedModelProvider, apiKey, @@ -295,9 +305,15 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } } -export function getDryRunInfo(options: { name: string; cwd: string; language?: string }): CreateResult { +export function getDryRunInfo(options: { + name: string; + cwd: string; + language?: string; + projectName?: string; +}): CreateResult { const { name, cwd, language } = options; - const projectRoot = join(cwd, name); + const projectName = options.projectName ?? name; + const projectRoot = join(cwd, projectName); const wouldCreate = [ `${projectRoot}/`, diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index af7317c0b..ac9d4b3ae 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -76,6 +76,8 @@ function printCreateSummary( /** Handle CLI mode with progress output */ async function handleCreateCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); + const name = options.name ?? options.projectName; + const projectName = options.projectName ?? name; const validation = validateCreateOptions(options, cwd); if (!validation.valid) { @@ -89,7 +91,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { // Handle dry-run mode if (options.dryRun) { - const result = getDryRunInfo({ name: options.name!, cwd, language: options.language }); + const result = getDryRunInfo({ name: name!, projectName, cwd, language: options.language }); if (options.json) { console.log(JSON.stringify(result)); } else { @@ -121,14 +123,15 @@ async function handleCreateCLI(options: CreateOptions): Promise { const result = skipAgent ? await createProject({ - name: options.name!, + name: projectName!, cwd, skipGit: options.skipGit, skipInstall: options.skipInstall, onProgress, }) : await createProjectWithAgent({ - name: options.name!, + name: name!, + projectName, cwd, type: options.type as 'create' | 'import' | undefined, buildType: (options.build as BuildType) ?? 'CodeZip', @@ -156,7 +159,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { if (options.json) { console.log(JSON.stringify(result)); } else if (result.success) { - printCreateSummary(options.name!, result.agentName, options.language, options.framework); + printCreateSummary(projectName!, result.agentName, options.language, options.framework); if (options.skipInstall) { console.log( "\nDependency installation was skipped. Run 'npm install' in agentcore/cdk/ and 'uv sync' in your agent directory manually." @@ -173,7 +176,11 @@ export const registerCreate = (program: Command) => { program .command('create') .description(COMMAND_DESCRIPTIONS.create) - .option('--name ', 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]') + .option('--name ', 'Resource name (agent or harness) [non-interactive]') + .option( + '--project-name ', + 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]' + ) .option('--no-agent', 'Skip agent creation [non-interactive]') .option('--defaults', 'Use defaults (Python, Strands, Bedrock, no memory) [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') @@ -225,6 +232,7 @@ export const registerCreate = (program: Command) => { // Any flag triggers non-interactive CLI mode const hasAnyFlag = Boolean( options.name ?? + options.projectName ?? (options.agent === false ? true : null) ?? options.defaults ?? options.build ?? diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index 26b83b200..f870ec1fc 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -2,6 +2,7 @@ import type { VpcOptions } from '../shared/vpc-utils'; export interface CreateOptions extends VpcOptions { name?: string; + projectName?: string; agent?: boolean; defaults?: boolean; type?: string; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 7d2026ce3..a59c7d752 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -1,4 +1,5 @@ import { + AgentNameSchema, BuildTypeSchema, ModelProviderSchema, ProjectNameSchema, @@ -36,18 +37,20 @@ export function validateFolderNotExists(name: string, cwd: string): true | strin export function validateCreateOptions(options: CreateOptions, cwd?: string): ValidationResult { // Name is required for non-interactive mode - if (!options.name) { + if (!options.name && !(options.agent === false && options.projectName)) { return { valid: false, error: '--name is required' }; } - // Validate name format - const nameResult = ProjectNameSchema.safeParse(options.name); - if (!nameResult.success) { - return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid project name' }; + const projectName = options.projectName ?? options.name!; + + // Validate project name format + const projectNameResult = ProjectNameSchema.safeParse(projectName); + if (!projectNameResult.success) { + return { valid: false, error: projectNameResult.error.issues[0]?.message ?? 'Invalid project name' }; } // Check if directory already exists - const folderCheck = validateFolderNotExists(options.name, cwd ?? process.cwd()); + const folderCheck = validateFolderNotExists(projectName, cwd ?? process.cwd()); if (folderCheck !== true) { return { valid: false, error: folderCheck }; } @@ -57,6 +60,11 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: true }; } + const agentNameResult = AgentNameSchema.safeParse(options.name); + if (!agentNameResult.success) { + return { valid: false, error: agentNameResult.error.issues[0]?.message ?? 'Invalid agent name' }; + } + // Import path: validate import-specific options if (options.type === 'import') { if (!options.agentId) return { valid: false, error: '--agent-id is required for import' };