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: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion src/apm_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -128,13 +149,17 @@ 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")
console.print(table)
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")

Expand All @@ -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 <owner>/<repo>",
"Compile agent context: apm compile",
"Edit your prompt: start.prompt.md (or .apm/prompts/start.prompt.md)",
"Run your first workflow: apm run start",
]

Expand Down
86 changes: 78 additions & 8 deletions tests/unit/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -46,15 +46,16 @@ 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()
finally:
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:
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/test_init_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading