# REQ-014: `ievo devinstall` — Symlink-Based Dev Installation

## Metadata
- **Status:** ready
- **Priority:** high
- **Dependencies:** REQ-000
- **Created:** 2026-03-05
- **Implemented:**

## Summary

Add `ievo devinstall` — a new active CLI command that creates the same project scaffold as `ievo init` but installs marketplace files as symlinks instead of copies. Any edit to a bundled agent, skill, or template in `src/ievo/marketplace/` is immediately visible in the dev-installed project without reinstalling. The command is idempotent: re-running it upgrades regular files to symlinks and refreshes any broken symlinks.

## Context

### Current install flow — `ievo init` / `sync_marketplace`

`ievo init` in `src/ievo/commands/init.py` creates the project scaffold (directories, manifest, CLAUDE.md, hooks, SPEC_INDEX). It then calls `sync_marketplace(root)` from `src/ievo/core/agent_sync.py`, which copies bundled marketplace files into the project using `shutil.copy2`:

- Agents: `src/ievo/marketplace/agents/<name>.md` → `.claude/agents/<name>.md`
- Skills: `src/ievo/marketplace/skills/<name>/SKILL.md` → `.claude/skills/<name>/SKILL.md`
- Templates (via `TEMPLATE_MAP` in `src/ievo/core/templates.py`):
  - `iEVO.md` → `.ievo/iEVO.md`
  - `REQ_TEMPLATE.md` → `spec/templates/REQ_TEMPLATE.md`
  - ... (7 templates total)

Every startup, `sync_marketplace` re-compares bundled file mtimes against `.ievo/registry.yaml` and re-copies changed files. This means any change to a bundled file propagates on next startup — but only if the CLI package was reinstalled (mtime changes when a new release is installed).

### Problem devinstall solves

During CLI development, the developer edits files in `src/ievo/marketplace/agents/` or `src/ievo/marketplace/skills/`. With a copy-based install, changes are NOT visible in the target project until `sync_marketplace` detects a mtime change (which happens naturally because the source file is edited). However:

1. If the project is in a different directory than the source, mtimes are compared against the INSTALLED package location, not the source tree.
2. In editable (`pip install -e`) installs, the marketplace files are the same files — but the mtime comparison still gates updates.

Symlinks solve both: the installed path IS the source path. No copy, no sync needed.

### Marketplace source location

The bundled marketplace lives at `Path(__file__).parent` relative to `src/ievo/marketplace/__init__.py`. Functions in `src/ievo/marketplace/__init__.py`:

```python
_PKG_DIR = Path(__file__).parent  # src/ievo/marketplace/
bundled_agent_path(name)   # returns _PKG_DIR / "agents" / f"{name}.md"
bundled_skill_path(name)   # returns _PKG_DIR / "skills" / name / "SKILL.md"
bundled_template_path(filename)  # returns _PKG_DIR / "templates" / filename
```

The `_PKG_DIR` path is the correct symlink target: it resolves to the actual file on disk whether the package is installed normally or in editable mode.

### What `ievo devinstall` must create

Same scaffold as `ievo init`, with these differences:

| Item | `ievo init` | `ievo devinstall` |
|------|------------|-------------------|
| `.claude/agents/<name>.md` | file copy | symlink → `_PKG_DIR/agents/<name>.md` |
| `.claude/skills/<name>/SKILL.md` | file copy | symlink → `_PKG_DIR/skills/<name>/SKILL.md` |
| `.ievo/iEVO.md` | file copy | symlink → `_PKG_DIR/templates/iEVO.md` |
| Other spec templates | file copy | symlink → `_PKG_DIR/templates/<name>` |
| Manifest, hooks, CLAUDE.md, SPEC_INDEX | same as init | same as init (copies, not symlinks) |

Symlinks use absolute paths to the source files (absolute paths survive project directory moves; relative paths break if cwd changes).

### Idempotency rules

- If `.claude/agents/<name>.md` already exists as a symlink pointing to the correct target → leave it unchanged.
- If `.claude/agents/<name>.md` already exists as a regular file (copied) → replace with symlink.
- If `.claude/agents/<name>.md` already exists as a broken symlink (target deleted) → replace with new symlink.
- If a source file does not exist (rare: bundled agent removed) → skip that item, log a warning.

### `devinstall` flag in manifest

The manifest (`.ievo/manifest.yaml`) gains a new field `devinstall: true` written by `ievo devinstall`. This field:
- Tells `sync_marketplace` to skip mtime-based re-copying of files that are symlinks (they don't need syncing — they always reflect the source).
- Is visible in the TUI Config tab's Project section (REQ-013).
- Is NOT set by `ievo init` (regular install leaves the field absent or `false`).

### Skills directory layout

Skills install as a directory: `.claude/skills/<name>/SKILL.md`. The symlink target for skills is the `SKILL.md` file itself (not the directory), matching the existing `sync_marketplace` pattern. The parent directory `.claude/skills/<name>/` is created as a real directory; only `SKILL.md` is a symlink.

### `ievo init` vs `ievo devinstall` relationship

`ievo devinstall` is an independent command. It does NOT call `ievo init` internally. Both commands share scaffold creation logic that must be extracted into a shared helper (`scaffold_project(root, project_name)`) to avoid duplication.

### `ievo devinstall --check` subcommand

An optional `--check` flag prints the symlink status of all marketplace files in the project: which are symlinks, which are regular files (not yet devinstalled), and which are broken. Exits 0 always. Does not modify any files.

### Data map additions

```
.ievo/manifest.yaml    # gains optional field: devinstall: true
```

No new directories are created beyond what `ievo init` creates.

### ProjectManifest model change

```python
@dataclass
class ProjectManifest:
    project: str
    version: str = "0.1.0"
    created: str = ""
    agents: dict[str, str] = field(default_factory=dict)
    overrides: dict[str, dict] = field(default_factory=dict)
    cli_version: str = ""
    devinstall: bool = False   # NEW — True when installed via ievo devinstall
```

`save()` must write `devinstall: true` when the field is `True`; omit it (or write `false`) when `False`.

## Acceptance Criteria

### AC-1: `ievo devinstall` creates the full project scaffold

- [ ] `ievo devinstall` in a directory with no existing project creates all directories: `.ievo/`, `spec/`, `spec/requirements/`, `spec/changes/`, `spec/questions/`, `spec/templates/`, `plans/`, `agents/`, `.claude/hooks/`, `.claude/skills/`, `.claude/agents/`
- [ ] `ievo devinstall` creates `.ievo/manifest.yaml` with `project`, `version`, `created`, `cli_version` fields
- [ ] `ievo devinstall` creates `CLAUDE.md` if it does not exist
- [ ] `ievo devinstall` creates `spec/SPEC_INDEX.md` if it does not exist
- [ ] `ievo devinstall` deploys `.claude/hooks/precompact_save.py` if it does not exist
- [ ] `ievo devinstall` creates `.claude/settings.local.json` if it does not exist

### AC-2: Agent files are installed as symlinks

- [ ] After `ievo devinstall`, `.claude/agents/<name>.md` is a symlink (not a regular file) for every bundled agent
- [ ] Each symlink target resolves to the bundled agent path (`bundled_agent_path(name)`) — an absolute path inside the ievo package
- [ ] Symlinks are created with absolute paths (not relative)
- [ ] When the symlink target file is edited, the content visible through the symlink changes immediately (no copy needed)

### AC-3: Skill files are installed as symlinks

- [ ] After `ievo devinstall`, `.claude/skills/<name>/SKILL.md` is a symlink for every bundled skill
- [ ] Each symlink target resolves to `bundled_skill_path(name)` (absolute path)
- [ ] The parent directory `.claude/skills/<name>/` is a real directory (not a symlink)

### AC-4: Template files are installed as symlinks

- [ ] After `ievo devinstall`, `.ievo/iEVO.md` is a symlink pointing to `bundled_template_path("iEVO.md")` (absolute)
- [ ] After `ievo devinstall`, `spec/templates/REQ_TEMPLATE.md` is a symlink (and all other entries in `TEMPLATE_MAP`)

### AC-5: `manifest.yaml` records `devinstall: true`

- [ ] After `ievo devinstall`, `.ievo/manifest.yaml` contains `devinstall: true`
- [ ] `ProjectManifest.load(root)` on a devinstall project returns a manifest with `devinstall == True`
- [ ] `ProjectManifest.load(root)` on a regular init project returns a manifest with `devinstall == False` (field absent or `false`)

### AC-6: Idempotency — re-running upgrades regular files to symlinks

- [ ] Running `ievo devinstall` in a project already initialized with `ievo init` (copy-based) converts `.claude/agents/<name>.md` regular files to symlinks
- [ ] Running `ievo devinstall` twice in a project leaves symlinks pointing to the same target (no error)
- [ ] A broken symlink (target file deleted) is replaced with a fresh symlink on re-run
- [ ] Non-marketplace files (manifest, CLAUDE.md, hooks) are not modified when they already exist (`_write_if_missing` pattern)

### AC-7: `ievo devinstall --check` reports symlink status

- [ ] `ievo devinstall --check` outputs a status line for each expected marketplace file: `[symlink]`, `[file]`, or `[broken symlink]`
- [ ] Output includes agents, skills, and templates
- [ ] Exit code is 0 regardless of status
- [ ] No files are created or modified

### AC-8: `ievo devinstall` is registered as a non-deprecated active command

- [ ] `ievo --help` lists `devinstall` in the commands table without `[DEPRECATED]` label
- [ ] `ievo devinstall --help` shows a help message describing symlink-based dev installation

### AC-9: `sync_marketplace` skips symlinks in devinstall projects

- [ ] When `manifest.devinstall == True`, `sync_marketplace(root)` does not overwrite files that are already valid symlinks (target exists)
- [ ] When `manifest.devinstall == True`, `sync_marketplace` still updates regular files (non-symlinked) if mtimes changed
- [ ] When `manifest.devinstall == False` (or absent), `sync_marketplace` behaves exactly as before

### AC-10: Source file not found — graceful skip

- [ ] If `bundled_agent_path(name)` returns `None` for a given agent name, `ievo devinstall` skips that agent and prints a warning: `warn("Agent <name> not found in bundled marketplace — skipping")`
- [ ] The command continues and installs all other agents successfully (does not abort)

## Negative Acceptance Criteria

- Does NOT add a deprecation warning — `ievo devinstall` is a new active command, not a deprecated alias
- Does NOT symlink the entire `.claude/agents/` directory — only individual `.md` files
- Does NOT symlink the `.claude/skills/<name>/` directory — only `SKILL.md` inside it
- Does NOT modify `.claude/hooks/` files — hooks are always deployed as copies (they run as scripts; symlinks can cause issues with some Python subprocess invocations)
- Does NOT add `devinstall: true` to projects initialized with `ievo init` (only `ievo devinstall` sets this field)
- Does NOT require the ievo package to be installed in editable mode — symlinks work with any install mode as long as `bundled_agent_path()` returns a valid path
- Does NOT remove existing project data (spec files, plans, agents already configured) when re-running on an existing project
- Does NOT sync templates on every startup for devinstall projects — symlinks always reflect the current source, no mtime-based re-copy needed
- Does NOT prompt the user to confirm before overwriting regular files with symlinks (replaces silently, like any upgrade)

## Technical Notes

### Symlink creation

```python
# Create symlink, replacing existing file or broken symlink
dest = root / ".claude" / "agents" / f"{name}.md"
src = bundled_agent_path(name)  # absolute Path

if dest.is_symlink() and dest.resolve() == src.resolve():
    pass  # already correct symlink — skip
elif dest.exists() or dest.is_symlink():
    dest.unlink()  # remove file or broken symlink
    dest.symlink_to(src)
else:
    dest.symlink_to(src)
```

### manifest.yaml devinstall field

```python
# In ProjectManifest.save():
data = {
    "project": self.project,
    "version": self.version,
    "created": self.created,
    "agents": self.agents,
    "cli_version": self.cli_version,
}
if self.devinstall:
    data["devinstall"] = True
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
```

### Command file

New file: `src/ievo/commands/devinstall.py`. Registered in `src/ievo/cli.py`:

```python
from ievo.commands import devinstall
app.command(name="devinstall", help="Dev-install marketplace files as symlinks.")(devinstall.devinstall)
```

### Shared scaffold helper

Extract init scaffold logic from `src/ievo/commands/init.py` into a function `scaffold_project(root: Path, project_name: str) -> None` callable by both `init.py` and `devinstall.py`. The scaffold function handles: directory creation, manifest creation/update, CLAUDE.md write-if-missing, CLAUDE.md patch, .gitignore write-if-missing, SPEC_INDEX write-if-missing, hooks write-if-missing, settings.local.json write-if-missing.

`devinstall` calls `scaffold_project()` then calls `devinstall_marketplace(root)` instead of `sync_marketplace(root)`.

### `--check` implementation

`ievo devinstall --check` iterates over the same items as `devinstall_marketplace()` and classifies each:

```python
if dest.is_symlink():
    if dest.exists():
        status = "[symlink]"
    else:
        status = "[broken symlink]"
elif dest.exists():
    status = "[file]"
else:
    status = "[missing]"
```

### Test file

New tests in `tests/test_devinstall.py`. Existing init tests must not break — scaffold extraction must be backward-compatible.

## Test Specification

### Tests in `tests/test_devinstall.py`

| Test | What it verifies |
|------|-----------------|
| `test_devinstall_creates_scaffold` | `ievo devinstall` creates all expected directories and files |
| `test_devinstall_agents_are_symlinks` | `.claude/agents/<name>.md` is a symlink after devinstall |
| `test_devinstall_agent_symlink_target_is_bundled_path` | Symlink target equals `bundled_agent_path(name)` |
| `test_devinstall_symlinks_use_absolute_paths` | Symlink paths are absolute (not relative) |
| `test_devinstall_skills_are_symlinks` | `.claude/skills/<name>/SKILL.md` is a symlink after devinstall |
| `test_devinstall_skill_parent_dir_is_real_dir` | `.claude/skills/<name>/` is a real directory, not a symlink |
| `test_devinstall_templates_are_symlinks` | `.ievo/iEVO.md` and other templates are symlinks |
| `test_devinstall_manifest_has_devinstall_true` | manifest.yaml contains `devinstall: true` |
| `test_devinstall_idempotent_on_symlinked_project` | Re-running leaves correct symlinks unchanged |
| `test_devinstall_upgrades_copy_to_symlink` | Regular file replaced with symlink on re-run |
| `test_devinstall_replaces_broken_symlink` | Broken symlink replaced with fresh symlink |
| `test_devinstall_check_reports_symlink` | `--check` prints `[symlink]` for a symlinked agent file |
| `test_devinstall_check_reports_file` | `--check` prints `[file]` for a copied agent file |
| `test_devinstall_check_reports_broken_symlink` | `--check` prints `[broken symlink]` when target deleted |
| `test_devinstall_check_exits_0` | `--check` exits 0 regardless of status |
| `test_devinstall_check_does_not_modify_files` | `--check` creates no new files |
| `test_devinstall_missing_bundled_agent_skips` | Agent with no bundled source → skipped with warning, others installed |
| `test_devinstall_registered_as_active_command` | `ievo --help` output contains `devinstall` without `[DEPRECATED]` |
| `test_sync_marketplace_skips_valid_symlinks` | `sync_marketplace` with `devinstall=True` manifest does not overwrite symlinks |
| `test_sync_marketplace_still_runs_for_regular_install` | `sync_marketplace` with `devinstall=False` behaves unchanged |
| `test_manifest_devinstall_field_round_trips` | `ProjectManifest(devinstall=True).save()` then `load()` returns `devinstall=True` |
| `test_manifest_load_default_devinstall_false` | Manifest file without `devinstall` field → `devinstall == False` |

### Tests to update in `tests/test_init.py`

| Test | What it verifies |
|------|-----------------|
| `test_init_scaffold_unchanged_after_extraction` | `ievo init` still creates all expected files (scaffold extraction is backward-compatible) |
