diff --git a/src/cli/commands/create/__tests__/create.test.ts b/src/cli/commands/create/__tests__/create.test.ts index 2051d1398..729cce707 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', () => { @@ -145,6 +157,82 @@ 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('with harness', () => { + it('uses --project-name for project and --name for harness resource', async () => { + const projectName = `HarnessProj${Date.now().toString().slice(-6)}`; + const harnessName = `HarnessResource${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const result = await runCLI( + ['create', '--project-name', projectName, '--name', harnessName, '--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(await exists(join(json.projectPath, 'app', harnessName, 'harness.json'))).toBeTruthy(); + + const projectSpec = JSON.parse(await readFile(join(json.projectPath, 'agentcore/agentcore.json'), 'utf-8')); + expect(projectSpec.name).toBe(projectName); + expect(projectSpec.harnesses[0].name).toBe(harnessName); + expect(projectSpec.harnesses[0].path).toBe(`app/${harnessName}`); + }); + + it('rejects long harness name without --project-name but accepts it with --project-name', async () => { + const harnessName = `Harness${'A'.repeat(30)}`; + const rejected = await runCLI(['create', '--name', harnessName, '--skip-install', '--json'], testDir); + expect(rejected.exitCode).toBe(1); + expect(JSON.parse(rejected.stdout).success).toBe(false); + + const projectName = `ShortProj${Date.now().toString().slice(-6)}`; + const accepted = await runCLI( + ['create', '--project-name', projectName, '--name', harnessName, '--skip-git', '--skip-install', '--json'], + testDir + ); + expect(accepted.exitCode, `stdout: ${accepted.stdout}, stderr: ${accepted.stderr}`).toBe(0); + expect(JSON.parse(accepted.stdout).success).toBe(true); + }); }); describe('--defaults', () => { @@ -163,12 +251,41 @@ describe('create command', () => { it('shows files without creating', async () => { const name = `DryRun${Date.now()}`; // --framework triggers agent path where --dry-run is supported - const result = await runCLI(['create', '--name', name, '--defaults', '--framework', 'Strands', '--dry-run'], testDir); + const result = await runCLI( + ['create', '--name', name, '--defaults', '--framework', 'Strands', '--dry-run'], + testDir + ); expect(result.exitCode).toBe(0); 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', + '--framework', + 'Strands', + '--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__/harness-action.test.ts b/src/cli/commands/create/__tests__/harness-action.test.ts index 07b36114d..73d26d13a 100644 --- a/src/cli/commands/create/__tests__/harness-action.test.ts +++ b/src/cli/commands/create/__tests__/harness-action.test.ts @@ -42,6 +42,26 @@ describe('createProjectWithHarness', () => { await expect(exists(join(harnessDir, 'system-prompt.md'))).resolves.toBe(true); }); + it('uses projectName for project scaffold and name for harness resource', async () => { + const projectName = `Proj${randomUUID().slice(0, 6)}`; + const name = `HarnessName${randomUUID().replace(/-/g, '').slice(0, 12)}`; + const result = await createProjectWithHarness({ + name, + projectName, + cwd: testDir, + modelProvider: 'bedrock', + modelId: 'global.anthropic.claude-sonnet-4-6', + skipGit: true, + skipInstall: true, + }); + + expect(result.success, `Error: ${result.error}`).toBe(true); + expect(result.projectPath).toBe(join(testDir, projectName)); + + await expect(exists(join(result.projectPath!, 'agentcore'))).resolves.toBe(true); + await expect(exists(join(result.projectPath!, 'app', name, 'harness.json'))).resolves.toBe(true); + }); + it('creates harness with custom options', async () => { const name = `CustomH${randomUUID().slice(0, 6)}`; const result = await createProjectWithHarness({ diff --git a/src/cli/commands/create/__tests__/harness-validate.test.ts b/src/cli/commands/create/__tests__/harness-validate.test.ts index 743edcd69..78acc0446 100644 --- a/src/cli/commands/create/__tests__/harness-validate.test.ts +++ b/src/cli/commands/create/__tests__/harness-validate.test.ts @@ -12,6 +12,7 @@ describe('validateCreateHarnessOptions', () => { testDir = join(tmpdir(), `harness-create-validate-${randomUUID()}`); mkdirSync(testDir, { recursive: true }); mkdirSync(join(testDir, 'existingHarness'), { recursive: true }); + mkdirSync(join(testDir, 'existingProject'), { recursive: true }); }); afterAll(() => { @@ -140,8 +141,11 @@ describe('validateCreateHarnessOptions', () => { expect(options.modelId).toBe('global.anthropic.claude-sonnet-4-6'); }); - it('accepts valid harness name with underscores', () => { - const result = validateCreateHarnessOptions({ name: 'my_valid_harness_123' }, testDir); + it('accepts valid harness name with underscores when project-name is valid', () => { + const result = validateCreateHarnessOptions( + { name: 'my_valid_harness_123', projectName: 'myValidHarness123' }, + testDir + ); expect(result.valid).toBe(true); }); @@ -149,4 +153,27 @@ describe('validateCreateHarnessOptions', () => { const result = validateCreateHarnessOptions({ name: 'a'.repeat(49) }, testDir); expect(result.valid).toBe(false); }); + + it('allows long harness name when project-name is valid', () => { + const result = validateCreateHarnessOptions( + { name: `Harness${'A'.repeat(30)}`, projectName: 'ShortProject' }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('validates project-name separately from harness name', () => { + const result = validateCreateHarnessOptions( + { name: 'ValidHarness', projectName: 'ProjectNameTooLongForCli' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Project name'); + }); + + it('checks folder existence using project-name', () => { + const result = validateCreateHarnessOptions({ name: 'ValidHarness2', projectName: 'existingProject' }, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('existingProject'); + }); }); 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 7a32d57a3..f19364d69 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -100,7 +100,7 @@ function printCreateSummary( } /** Print completion summary after successful harness create */ -function printCreateHarnessSummary(projectName: string): void { +function printCreateHarnessSummary(projectName: string, harnessName: string): void { const green = '\x1b[32m'; const cyan = '\x1b[36m'; const dim = '\x1b[2m'; @@ -112,7 +112,7 @@ function printCreateHarnessSummary(projectName: string): void { console.log(`${dim}Created:${reset}`); console.log(` ${projectName}/`); console.log(` agentcore/ ${dim}Config and CDK project${reset}`); - console.log(` app/${projectName}/ ${dim}Harness config${reset}`); + console.log(` app/${harnessName}/ ${dim}Harness config${reset}`); console.log(''); // Success and next steps @@ -127,10 +127,13 @@ function printCreateHarnessSummary(projectName: string): void { /** Handle CLI mode for the harness path */ async function handleCreateHarnessCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); + const name = options.name ?? options.projectName; + const projectName = options.projectName ?? name; const validation = validateCreateHarnessOptions( { - name: options.name, + name, + projectName, modelProvider: options.modelProvider, modelId: options.modelId, apiKeyArn: options.apiKeyArn, @@ -169,7 +172,8 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { const containerOption = harnessPrimitive.parseContainerFlag(options.container); const result = await createProjectWithHarness({ - name: options.name!, + name: name!, + projectName: projectName!, cwd, modelProvider: provider, modelId, @@ -195,7 +199,7 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { if (options.json) { console.log(JSON.stringify(result)); } else if (result.success) { - printCreateHarnessSummary(options.name!); + printCreateHarnessSummary(projectName!, name!); } else { console.error(result.error); } @@ -205,6 +209,8 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { /** Handle CLI mode with progress output for the agent/runtime path */ async function handleCreateAgentCLI(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) { @@ -218,7 +224,7 @@ async function handleCreateAgentCLI(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: projectName!, cwd, language: options.language }); if (options.json) { console.log(JSON.stringify(result)); } else { @@ -250,14 +256,15 @@ async function handleCreateAgentCLI(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: projectName!, cwd, type: options.type as 'create' | 'import' | undefined, buildType: (options.build as BuildType) ?? 'CodeZip', @@ -285,7 +292,7 @@ async function handleCreateAgentCLI(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." @@ -302,7 +309,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 [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') @@ -356,6 +367,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/harness-action.ts b/src/cli/commands/create/harness-action.ts index 84c97fc75..6733a780d 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -8,6 +8,7 @@ import { join } from 'path'; export interface CreateHarnessProjectOptions { name: string; + projectName?: string; cwd: string; modelProvider: HarnessModelProvider; modelId: string; @@ -31,10 +32,11 @@ export interface CreateHarnessProjectOptions { } export async function createProjectWithHarness(options: CreateHarnessProjectOptions): Promise { - const { name, cwd, skipGit, skipInstall, onProgress } = options; + const { name, projectName: explicitProjectName, cwd, skipGit, skipInstall, onProgress } = options; + const projectName = explicitProjectName ?? name; const projectResult = await createProject({ - name, + name: projectName, cwd, skipGit, skipInstall, diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts index 54cb82105..52f84d6e2 100644 --- a/src/cli/commands/create/harness-validate.ts +++ b/src/cli/commands/create/harness-validate.ts @@ -1,8 +1,9 @@ -import { HarnessNameSchema } from '../../../schema'; +import { HarnessNameSchema, ProjectNameSchema } from '../../../schema'; import { validateFolderNotExists } from './validate'; export interface CreateHarnessCliOptions { name?: string; + projectName?: string; modelProvider?: string; modelId?: string; apiKeyArn?: string; @@ -50,12 +51,18 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c return { valid: false, error: '--name is required' }; } + const projectName = options.projectName ?? options.name; + const projectNameResult = ProjectNameSchema.safeParse(projectName); + if (!projectNameResult.success) { + return { valid: false, error: projectNameResult.error.issues[0]?.message ?? 'Invalid project name' }; + } + const nameResult = HarnessNameSchema.safeParse(options.name); if (!nameResult.success) { return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid harness name' }; } - const folderCheck = validateFolderNotExists(options.name, cwd ?? process.cwd()); + const folderCheck = validateFolderNotExists(projectName, cwd ?? process.cwd()); if (folderCheck !== true) { return { valid: false, error: folderCheck }; } diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index 0f31921f6..0f4211eb3 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' };