diff --git a/CHANGELOG.md b/CHANGELOG.md index 1307da03..1a16e56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install` no longer silently drops skills, agents, and commands when a Claude Code plugin also ships `hooks/*.json`. The package-type detection cascade now classifies plugin-shaped packages as `MARKETPLACE_PLUGIN` (which already maps hooks via the plugin synthesizer) before falling back to the hook-only classification, and emits a default-visibility `[!]` warning when a hook-only classification disagrees with the package's directory contents (#780) - Preserve custom git ports across protocols: non-default ports on `ssh://` and `https://` dependency URLs (e.g. Bitbucket Datacenter on SSH port 7999, self-hosted GitLab on HTTPS port 8443) are now captured as a first-class `port` field on `DependencyReference` and threaded through all clone URL builders. When the SSH clone fails, the HTTPS fallback reuses the same port instead of silently dropping it (#661, #731) - Detect port-like first path segment in SCP shorthand (`git@host:7999/path`) and raise an actionable error suggesting the `ssh://` URL form, instead of silently misparsing the port as part of the repository path (#784) +- `apm init` now creates `start.prompt.md` so `apm run start` works out of the box; Next Steps panel no longer references `apm compile` (#649) ## [0.8.12] - 2026-04-19 @@ -36,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644) - Deploy primitives from the project root's own `.apm/` directory alongside declared dependencies, so single-package projects no longer need a sub-package stub to install their own content (#715) - Add `temp-dir` configuration key (`apm config set temp-dir PATH`) to override the system temporary directory, resolving `[WinError 5] Access is denied` in corporate Windows environments (#629) - ### Changed - Refactor `apm install` into a modular engine package (`apm_cli/install/`) with discrete phases (resolve, targets, download, integrate, cleanup, lockfile, finalize, post-deps local) and apply design patterns -- introduce a `DependencySource` Strategy hierarchy with shared `run_integration_template()` Template Method (kills ~300 LOC duplication across local/cached/fresh dep handlers), add `services.py` DI seam to eliminate `_install_mod` indirection, and wrap the pipeline in a typed `InstallService` Application Service consuming a frozen `InstallRequest`. `install/phases/integrate.py` shrinks from 1013 to ~400 LOC; the public `apm install` behaviour and CLI surface are unchanged. Preserves the `#762` cleanup chokepoint and remains backward-compatible (`_install_apm_dependencies` re-export and 55 healthy test patches keep working) (#764) diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index f97ff46d..8fa705d1 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -25,6 +25,17 @@ _validate_project_name, ) +START_PROMPT_FILENAME = "start.prompt.md" + +START_PROMPT_TEMPLATE = """\ +--- +name: start +--- +You are a helpful assistant working on this project. + +Help me understand the codebase and suggest improvements. +""" + @click.command(help="Initialize a new APM project") @click.argument("project_name", required=False) @@ -115,6 +126,16 @@ def init(ctx, project_name, yes, plugin, verbose): # Create apm.yml (with devDependencies for plugin mode) _create_minimal_apm_yml(config, plugin=plugin) + # Create start.prompt.md for regular projects (not plugin mode) + start_prompt_created = False + if not plugin: + start_prompt_path = Path(START_PROMPT_FILENAME) + if not start_prompt_path.exists(): + start_prompt_path.write_text(START_PROMPT_TEMPLATE, encoding="utf-8") + start_prompt_created = True + else: + logger.progress(f"{START_PROMPT_FILENAME} already exists, skipping") + # Create plugin.json for plugin mode if plugin: _create_plugin_json(config) @@ -128,6 +149,8 @@ def init(ctx, project_name, yes, plugin, verbose): files_data = [ ("*", APM_YML_FILENAME, "Project configuration"), ] + if start_prompt_created: + files_data.append(("*", START_PROMPT_FILENAME, "Starter prompt")) if plugin: files_data.append(("*", "plugin.json", "Plugin metadata")) table = _create_files_table(files_data, title="Created Files") @@ -135,6 +158,8 @@ def init(ctx, project_name, yes, plugin, verbose): except (ImportError, NameError): logger.progress("Created:") click.echo(" * apm.yml - Project configuration") + if start_prompt_created: + click.echo(f" * {START_PROMPT_FILENAME} - Starter prompt") if plugin: click.echo(" * plugin.json - Plugin metadata") @@ -150,7 +175,7 @@ def init(ctx, project_name, yes, plugin, verbose): next_steps = [ "Install a runtime: apm runtime setup copilot", "Add APM dependencies: apm install /", - "Compile agent context: apm compile", + "Edit your prompt: start.prompt.md (or .apm/prompts/start.prompt.md)", "Run your first workflow: apm run start", ] diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 135d91f0..4b1f9a68 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -36,7 +36,7 @@ def teardown_method(self): os.chdir(str(repo_root)) def test_init_current_directory(self): - """Test initialization in current directory (minimal mode).""" + """Test initialization in current directory.""" with tempfile.TemporaryDirectory() as tmp_dir: os.chdir(tmp_dir) try: @@ -46,7 +46,8 @@ def test_init_current_directory(self): assert result.exit_code == 0 assert "APM project initialized successfully!" in result.output assert Path("apm.yml").exists() - # Minimal mode: no template files created + assert Path("start.prompt.md").exists() + # No extra template files created assert not Path("hello-world.prompt.md").exists() assert not Path("README.md").exists() assert not Path(".apm").exists() @@ -54,7 +55,7 @@ def test_init_current_directory(self): os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup def test_init_explicit_current_directory(self): - """Test initialization with explicit '.' argument (minimal mode).""" + """Test initialization with explicit '.' argument.""" with tempfile.TemporaryDirectory() as tmp_dir: os.chdir(tmp_dir) try: @@ -64,13 +65,14 @@ def test_init_explicit_current_directory(self): assert result.exit_code == 0 assert "APM project initialized successfully!" in result.output assert Path("apm.yml").exists() - # Minimal mode: no template files created + assert Path("start.prompt.md").exists() + # No extra template files created assert not Path("hello-world.prompt.md").exists() finally: os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup def test_init_new_directory(self): - """Test initialization in new directory (minimal mode).""" + """Test initialization in new directory.""" with tempfile.TemporaryDirectory() as tmp_dir: os.chdir(tmp_dir) try: @@ -84,7 +86,8 @@ def test_init_new_directory(self): assert project_path.exists() assert project_path.is_dir() assert (project_path / "apm.yml").exists() - # Minimal mode: no template files created + assert (project_path / "start.prompt.md").exists() + # No extra template files created assert not (project_path / "hello-world.prompt.md").exists() assert not (project_path / "README.md").exists() assert not (project_path / ".apm").exists() @@ -221,7 +224,7 @@ def test_init_existing_project_interactive_cancel(self): os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup def test_init_validates_project_structure(self): - """Test that init creates minimal project structure.""" + """Test that init creates expected project structure.""" with tempfile.TemporaryDirectory() as tmp_dir: os.chdir(tmp_dir) try: @@ -243,7 +246,9 @@ def test_init_validates_project_structure(self): assert "scripts" in config assert config["scripts"] == {} - # Minimal mode: no template files created + # start.prompt.md created + assert (project_path / "start.prompt.md").exists() + # No extra template files created assert not (project_path / "hello-world.prompt.md").exists() assert not (project_path / "README.md").exists() assert not (project_path / ".apm").exists() @@ -296,6 +301,71 @@ def test_init_does_not_create_skill_md(self): finally: os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup + def test_init_creates_start_prompt_md(self): + """Test that init creates start.prompt.md with correct content.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "--yes"]) + + assert result.exit_code == 0 + prompt_path = Path("start.prompt.md") + assert prompt_path.exists() + + content = prompt_path.read_text(encoding="utf-8") + assert "---" in content + assert "name: start" in content + assert "You are a helpful assistant" in content + finally: + os.chdir(self.original_dir) + + def test_init_does_not_overwrite_existing_start_prompt(self): + """Test that init preserves existing start.prompt.md (brownfield).""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + # Create existing start.prompt.md with custom content + Path("start.prompt.md").write_text( + "---\nname: start\n---\nMy custom prompt\n", encoding="utf-8" + ) + + result = self.runner.invoke(cli, ["init", "--yes"]) + + assert result.exit_code == 0 + content = Path("start.prompt.md").read_text(encoding="utf-8") + assert "My custom prompt" in content + assert "start.prompt.md already exists" in result.output + finally: + os.chdir(self.original_dir) + + def test_init_next_steps_no_compile(self): + """Test that next steps do not reference apm compile.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "--yes"]) + + assert result.exit_code == 0 + assert "apm compile" not in result.output + assert "Edit your prompt" in result.output + assert "start.prompt.md" in result.output + assert "apm run start" in result.output + finally: + os.chdir(self.original_dir) + + def test_init_created_files_table_includes_start_prompt(self): + """Test that Created Files table lists start.prompt.md.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "--yes"]) + + assert result.exit_code == 0 + # start.prompt.md appears in the Created Files table + assert "start.prompt.md" in result.output + finally: + os.chdir(self.original_dir) + class TestPluginNameValidation: diff --git a/tests/unit/test_init_plugin.py b/tests/unit/test_init_plugin.py index 60faf470..093db38b 100644 --- a/tests/unit/test_init_plugin.py +++ b/tests/unit/test_init_plugin.py @@ -284,6 +284,19 @@ def test_plugin_json_ends_with_newline(self): finally: os.chdir(self.original_dir) + def test_plugin_does_not_create_start_prompt(self): + """start.prompt.md is NOT created in plugin mode.""" + with tempfile.TemporaryDirectory() as tmp_dir: + project_dir = Path(tmp_dir) / "my-plugin" + project_dir.mkdir() + os.chdir(project_dir) + try: + result = self.runner.invoke(cli, ["init", "--plugin", "--yes"]) + assert result.exit_code == 0, result.output + assert not Path("start.prompt.md").exists() + finally: + os.chdir(self.original_dir) + def test_plugin_apm_yml_has_dependencies(self): """apm.yml created with --plugin still has regular dependencies section.""" with tempfile.TemporaryDirectory() as tmp_dir: