feat(install, compile): --root DIR to redirect writes while sources resolve from $PWD#928
feat(install, compile): --root DIR to redirect writes while sources resolve from $PWD#928srid wants to merge 7 commits into
--root DIR to redirect writes while sources resolve from $PWD#928Conversation
`apm install --root <dir>` writes apm_modules/, apm.lock.yaml, and runtime deployment dirs (.claude/, .codex/, .agents/, .opencode/) under <dir> while sources (apm.yml, .apm/, local-path packages) continue resolving from $PWD. Mirrors `pip install --target` and `npm install --prefix`. Useful for scratch-dir verification (microsoft#684), bootstrap scripts, and fixture generation -- closes microsoft#888. Implementation - core/scope.py: process-global deploy-root override (set_deploy_root_override) plus a separate get_source_root() that always resolves to $PWD. get_manifest_path is decoupled from get_apm_dir so the manifest stays in $PWD even when writes redirect. - install/context.py: InstallContext gains source_root, defaulting to project_root for back-compat. - install/pipeline.py: passes source_root into the context. _project_has_root_primitives runs against source_root. - install/services.py: integrate_local_content takes an optional source_root for the synthetic _local package's install_path. - install/phases/{resolve,integrate}.py: thread source_root through to local-package resolution and local-content integration. - commands/install.py: --root option, mutually exclusive with --global; sets the override at entry, clears it in finally so no global state leaks across invocations.
Replace the deploy-root override with a chdir-based redirect so every existing site that hardcodes Path.cwd() / os.getcwd() (notably the MCP adapters in apm_cli.adapters.client.*) automatically resolves to the deploy root. Sources keep reading from the original $PWD via set_source_root_override. Why chdir - The previous override only covered scope helpers (get_deploy_root/get_apm_dir). MCP adapters bypass those helpers and write directly to Path.cwd() / opencode.json, .vscode/mcp.json, .cursor/mcp.json, etc. Refactoring the long tail of cwd/getcwd call-sites is more invasive than the chdir trick and would block this feature on a wider cleanup. Implementation - new module: apm_cli.install.root_redirect.install_root_redirect context manager (chdir + source-root pin, restored on exit). - core/scope.py: replace deploy override with source-root override; get_manifest_path now derives from get_source_root. - commands/install.py: bracket the handler body with the context manager (enter at top, __exit__ in finally). The Click option block is compressed onto one line per option to recover budget. - tests/unit/install/test_architecture_invariants.py: bump LOC budget 1700 -> 1725 with rationale (mirrors the prior PR pattern); the pending --mcp extraction recovers this budget.
The dependency resolver loads ``project_root / "apm.yml"`` for the root manifest. Before this fix it received ``ctx.apm_dir`` -- which under ``apm install --root`` points at the (typically empty) deploy directory, causing the resolver to silently return zero direct deps. Pass ``ctx.source_root`` instead so the manifest resolves from $PWD even when writes redirect. Falls back to ``ctx.apm_dir`` when no override is active so the default path stays unchanged.
Sources continue resolving from $PWD; AGENTS.md / CLAUDE.md outputs land under DIR. Pairs with `apm install --root` for scratch-dir verification (microsoft#888) -- the install + compile combo needs no rsync, cd-gymnastics, or symlinks. Implementation - AgentsCompiler / DistributedAgentsCompiler take an optional source_dir parameter (defaults to base_dir for back-compat). base_dir continues to drive write paths and placement targets; source_dir is used for primitive discovery, project-tree scoring (ContextOptimizer), and constitution lookup. - DistributedAgentsCompiler._source_to_base translates the placement map keys from source-dir-rooted to base-dir-rooted so writes land at the deploy root. - template_builder.build_conditional_sections takes an optional source_dir to compute display-relative paths in `<!-- Source: -->` comments. Without this, scratch-compiled output renders absolute source paths and diverges from in-place compile output. - distributed_compiler's per-instruction source attribution renders paths relative to source_dir (was self.base_dir). - commands/compile/cli.py: --root option, brackets the handler with compile_root_redirect (alias for install_root_redirect; identical chdir + source-root pin). Source-root reads (apm.yml existence, .apm/ scan, find_constitution, target detection, AgentsCompiler) go through get_source_root. - install/root_redirect.py: re-exports the helper as compile_root_redirect; the two commands share one implementation.
Pre-merge structural review (Hickey + Lowy)I ran two structural-review passes on this branch — one Hickey-style ("Simple Made Easy", looking for complecting and missed deduplication) and one Lowy-style (volatility-based decomposition, after Parnas '72). Background on what each pass checks: kolu.dev/blog/hickey-lowy. Posting both before maintainer review since the two passes disagree on the central design choice and I'd rather hear your opinion than commit to one direction blindly. Where they convergeCorrectness bug under Missing cross-reference docstring on Where they disagreeThe headline: chdir + process-global override vs. just-the-override-and-refactor-cwd-callsites.
This is the tradeoff I called out in the PR description's "draft status" section; the two reviewers come down on opposite sides. I lean Hickey's way (smaller diff, contained surface) but Lowy's argument that the long tail is the volatility worth encapsulating is real. Maintainer call: which? Lowy's other architectural pushes
Hickey's other style pushes
Optional / defer
AskingThe MUST-FIX correctness bug + docstring will get fixed regardless. For the rest, especially the chdir-vs-refactor call, I'd rather have your direction before I rewrite. Happy to push either way; the kolu downstream consumer (juspay/kolu#716) is in draft pending this PR's resolution and isn't blocking. 🤖 Reviews generated with structural-analysis subagents via Claude Code (background: kolu.dev/blog/hickey-lowy). |
AgentsCompiler.validate_primitives and the verbose-output helper at the foot of agents_compiler.py rendered primitive / instruction file paths via `portable_relpath(file_path, self.base_dir)`. Under `apm compile --root`, source files live under `source_dir` while `base_dir` is the deploy root -- the two don't share a common parent, so the relpath either returned `../../...` chains or the absolute path. DistributedAgentsCompiler already does this right at distributed_compiler.py:594; this closes the asymmetry. Also documents the source-vs-deploy routing convention on `get_lockfile_dir` so a future maintainer adding a new metadata helper picks the right root without tracing callers. Found by a Hickey-style structural review of the --root branch: microsoft#928 (comment)
|
Pushed fix for the two MUST-FIX items (commit 19c20e1):
The should-fix / chdir-vs-refactor items still await your call. Draft stays draft until then. |
Resolved conflicts across install + compile + scope + tests; folded in the Hickey/Lowy review fixes that were pending on PR microsoft#928. Conflict resolutions follow upstream's Ruff convention (PEP 604 ``X | None`` over ``Optional[X]`` with ``# noqa: F401, UP035`` on the legacy import): - ``commands/install.py``: kept upstream's ``_install_apm_packages`` / ``_post_install_summary`` decomposition; threaded ``--root`` Click option + ``install_root_redirect`` context manager through the refactored handler. - ``commands/compile/cli.py``: source root reads (``apm.yml`` detection, ``detect_target`` project_root) wired through ``get_source_root(InstallScope.PROJECT)`` so they survive ``--root``'s chdir. - ``compilation/agents_compiler.py`` + ``distributed_compiler.py``: preserved the ``source_dir`` parameter end-to-end so primitive paths in validation warnings, source-attribution comments, and verbose-trace lines render against the source root. - ``install/services.py`` + ``phases/integrate.py``: kept ``source_root=`` thread-through alongside upstream's new ``ctx=`` parameter to ``integrate_local_content``. - ``install/context.py``: kept upstream's ``cowork_nonsupported_warned`` field; collapsed conflicting docstrings. Hickey/Lowy fixes (microsoft#928 (comment)...): - (Lowy / should-fix) ``InstallContext.source_root`` is now required, not ``Optional[Path] = None``. Resolved at the CLI boundary in ``run_install_pipeline``. Dropped the ``ctx.source_root or ctx.project_root`` and ``ctx.source_root or ctx.apm_dir`` fallbacks in ``phases/resolve.py`` and ``install/sources.py`` -- they masked bugs whenever ``source_root != project_root``. - (Hickey / should-fix) Replaced manual ``__enter__`` / ``finally: __exit__`` pairs with ``with install_root_redirect(...)`` / ``with compile_root_redirect(...)`` in both commands. Re-indents the handler body but unwinds correctly even on early ``return`` or uncaught exceptions, and removes the ``_root_redirect`` reference from the function frame. - (Hickey / should-fix) Added a "Source-vs-deploy root convention" section to ``install/pipeline.py``'s module docstring spelling out which root each phase reads vs writes. Future field additions must pick a side deliberately. - (Lowy / optional) Expanded the comment on ``compile_root_redirect = install_root_redirect`` to document why the alias exists and when it should split. Test fixes for the now-required ``source_root`` field: - ``test_direct_dep_failure.py`` and ``test_no_policy_flag.py``: pass ``source_root=`` alongside ``project_root`` / ``apm_dir`` in ``InstallContext(...)`` constructions. - ``test_architecture_invariants.py``: kept upstream's 1800-LOC ``commands/install.py`` budget; the ``--root`` plumbing fits in the headroom thanks to upstream's parallel decomposition. Verified end-to-end: ``apm install --root SCRATCH`` + ``apm compile --root SCRATCH --target codex,opencode`` produces byte-identical AGENTS.md files to an in-place compile (modulo the non-deterministic Build ID). All install + compilation unit tests pass (656 + arch-invariant suite); the pre-existing 23 failures elsewhere are environmental (missing ``ruamel`` in the local venv, missing ``python``/``llm`` in PATH on Nix bare shell) and unrelated to this merge.
|
Pushed Conflict resolution — followed upstream's PEP 604 / Ruff convention; threaded Addressed findings:
The chdir-vs-refactor design call is still open for maintainer direction — that's the bigger sweep through Verified end-to-end: The kolu downstream consumer (juspay/kolu#716) will be re-pointed at this commit next. |
There was a problem hiding this comment.
Pull request overview
Adds a new --root DIR flag to apm install and apm compile to redirect deployment writes into DIR while continuing to resolve sources (e.g., apm.yml, .apm/, local-path deps, and compile placement scanning) from the original working directory ($PWD). This is implemented via a shared chdir + source-root pin mechanism and by threading a distinct “source vs deploy root” through the install and compilation pipelines.
Changes:
- Add
--root DIRtoapm installandapm compile, backed by a sharedinstall_root_redirectcontext manager (compile reuses it via alias). - Split source-vs-deploy path semantics in install (
InstallContext.source_root, resolver and local-path handling) so--rootdoesn’t break dependency resolution or local content integration. - Extend compiler internals to accept a separate
source_dirfor discovery/scoring while still writing outputs under the deploy root, keeping attribution paths stable.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/install/test_no_policy_flag.py | Updates InstallContext construction to include the new required source_root. |
| tests/unit/install/test_direct_dep_failure.py | Updates minimal InstallContext fixture to pass source_root. |
| tests/unit/install/test_architecture_invariants.py | Updates install.py LOC-budget rationale to account for --root plumbing. |
| src/apm_cli/install/sources.py | Ensures local package copying uses ctx.source_root (source tree) rather than deploy root. |
| src/apm_cli/install/services.py | Adds source_root to local-content integration so synthetic _local reads from sources, not deploy root. |
| src/apm_cli/install/root_redirect.py | Introduces the shared chdir + source-root override context manager used by install/compile --root. |
| src/apm_cli/install/pipeline.py | Computes both deploy root and source root; documents the convention for phases. |
| src/apm_cli/install/phases/resolve.py | Resolves local-path deps relative to source_root and points the resolver at the source root (manifest location). |
| src/apm_cli/install/phases/integrate.py | Passes ctx.source_root into local-content integration. |
| src/apm_cli/install/context.py | Adds required source_root field to enforce explicit root selection in phases. |
| src/apm_cli/core/scope.py | Adds process-global source_root override and updates get_manifest_path() to follow source root. |
| src/apm_cli/compilation/template_builder.py | Adds source_dir parameter for stable <!-- Source: ... --> relative path rendering. |
| src/apm_cli/compilation/distributed_compiler.py | Adds source_dir distinct from base_dir, translating placement keys so writes land under deploy root. |
| src/apm_cli/compilation/agents_compiler.py | Threads source_dir through primitive discovery, warnings, and distributed compilation. |
| src/apm_cli/commands/install.py | Adds --root option and wraps command execution in install_root_redirect. |
| src/apm_cli/commands/compile/cli.py | Adds --root option and wraps command execution in compile_root_redirect, using source_root for reads. |
Copilot's findings
- Files reviewed: 16/16 changed files
- Comments generated: 8
| # Watch mode | ||
| if watch: | ||
| _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) | ||
| return |
| from ..core.scope import set_source_root_override | ||
|
|
||
| target = Path(root) | ||
| target.mkdir(parents=True, exist_ok=True) | ||
| original = Path.cwd() |
| # Create structured logger for install output early so exception | ||
| # handlers can always reference it (avoids UnboundLocalError if | ||
| # scope initialisation below throws). | ||
| is_partial = bool(packages) | ||
| logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) |
| # Removed volatile timestamp for deterministic builds | ||
| version: str | ||
| chatmode_content: str | None = None | ||
|
|
| @click.option( | ||
| "--root", "root", type=click.Path(file_okay=False, resolve_path=True), | ||
| default=None, metavar="DIR", | ||
| help=("Install into DIR instead of $PWD: apm_modules/, apm.lock.yaml, " | ||
| ".claude/, .codex/, .agents/, .opencode/ are written under DIR " |
| @click.option( | ||
| "--root", "root", type=click.Path(file_okay=False, resolve_path=True), | ||
| default=None, metavar="DIR", | ||
| help=("Write AGENTS.md / CLAUDE.md outputs under DIR instead of $PWD; " | ||
| "sources (apm.yml, .apm/, project tree for placement scoring) " |
| @contextmanager | ||
| def install_root_redirect(root: Optional[str | os.PathLike]) -> Iterator[None]: | ||
| """Redirect deploy-side writes into *root* for the wrapped block. | ||
|
|
||
| When *root* is ``None`` or empty, this is a no-op so callers can |
| from ..constants import APM_YML_FILENAME | ||
|
|
||
| return get_apm_dir(scope) / APM_YML_FILENAME | ||
| if scope is InstallScope.USER: | ||
| return Path.home() / USER_APM_DIR / APM_YML_FILENAME | ||
| return get_source_root(scope) / APM_YML_FILENAME |
This brings the branch up to date with origin/main (which advanced significantly: --frozen flag, local-bundle install, --refresh, --as, --legacy-skill-paths, --all flag for compile, parse_targets_field canonical plural support, install_started_at timing, etc.) and folds in Copilot's automated review feedback. Conflict resolution: kept upstream's body wholesale and re-threaded the --root + source_root substitutions on top. install.py LOC budget raised 2010 -> 2050 to absorb the --root option declaration, the --root + --global UsageError, and the with-block re-indent (re-indentation does not add lines but the option/preamble does). Review fixes (microsoft#928 (review)): * compile/cli.py: reject --root + --watch with UsageError -- the watch loop uses bare-relative paths (Path(APM_DIR), AgentsCompiler(".")) and would scan the deploy root. * root_redirect.py: thread dry_run through; refuse to mkdir the deploy target on previews so apm install --dry-run --root DIR cannot leak a directory creation onto disk. * commands/install.py: logger UnboundLocalError -- already addressed upstream (logger = None initialized before try; except handlers guard with `if logger`). Preserved through the merge. * template_builder.py: E305 -- two blank lines between TemplateData dataclass and render_instructions_block (was one). * core/scope.py: get_manifest_path USER branch delegates to get_apm_dir(scope) / APM_YML_FILENAME so the user-scope location cannot drift between helpers. * tests/unit/install/test_root_redirect.py: NEW. 8 tests covering no-op-when-None, no-op-when-empty, chdir+override on entry, restore-on-exception, mkdir-on-write, dry-run-refuses-mkdir, dry-run-with-existing-dir, compile-alias-identity. * docs/src/content/docs/reference/cli-commands.md + packages/apm-guide/.apm/skills/apm-usage/commands.md: documented --root for both install and compile per repo doc rules; called out --global / --watch incompatibility and --dry-run mkdir-refusal. Verified: * 1039 install + compilation unit tests pass (656 baseline + new --root tests + the larger upstream surface). * Ruff clean on every touched file (UP035 + I001 fixed during resolution). * End-to-end: apm install --root SCRATCH + apm compile --root SCRATCH --target codex,opencode produces byte-identical AGENTS.md to an in-place compile (Build ID modulo). * CLI rejections: --root + --watch, --root + --global, and --dry-run + --root with missing DIR all emit a click.UsageError exit 2.
|
Pushed Review findings — addressed:
Verified end-to-end:
The chdir-vs-refactor design call is still open for maintainer direction (the bigger sweep through |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Pragmatic chdir+global pattern works for the CLI single-invocation case but carries two latent correctness faults: non-reentrant context manager and unguarded global under threads. |
| CLI Logging Expert | 0 | 2 | 2 | No user-visible signal that --root is active; silent chdir means users cannot confirm where files landed without checking the filesystem. |
| DevX UX Expert | 0 | 2 | 2 | Docs updated, help text solid; two ergonomic hazards: --dry-run DIR-must-exist constraint inverts expectations, and the split read/write root silently misfires when apm.yml is absent from $PWD. |
| Supply Chain Security Expert | 0 | 2 | 1 | chdir+global-override pair is not concurrency-safe; --root itself has no path containment guard, enabling silent writes to filesystem root or home. |
| OSS Growth Hacker | 0 | 2 | 2 | --root flag unlocks Docker, CI matrix, and monorepo installs; strong story beat but needs a CHANGELOG entry and one Docker snippet to convert. |
| Auth Expert | 0 | 1 | 0 | chdir to --root before lazy git credential fill may silently drop repo-local credential helpers; env-var and gh-auth paths unaffected. |
| Doc Writer | 0 | 0 | 3 | --root is documented in both the CLI reference and the apm-usage skill; instructions and SKILL.md are consistent and accurate. |
| Test Coverage Expert | 0 | 2 | 1 | Unit coverage for root_redirect context manager is solid (8 tests, state-unwind verified); --global+--root UsageError has no executable test; install --root deploy-path promise lacks integration-tier coverage. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test Coverage Expert] Add integration-with-fixtures test: apm install --root (dir) writes apm_modules/, lockfile, and runtime dirs under DIR; sources resolve from $PWD -- Missing-evidence finding on the core user promise. No unit test on root_redirect.py can verify the cross-module resolve->integrate->lockfile-write flow. This is a regression trap on the primary behavioral contract of the flag.
- [Supply Chain Security Expert] Add path containment guard before mkdir in root_redirect.py; call validate_path_segments and warn/require --yes when target resolves to $HOME directly -- A user typo (--root / or --root ~) silently creates .claude/, .codex/, and apm_modules/ under filesystem root or user home. validate_path_segments() already exists; wiring it is a one-liner with outsized blast-radius protection.
- [Test Coverage Expert] Add unit test: apm install --global --root (dir) exits non-zero with click.UsageError matching 'not valid with --global' -- Missing-evidence finding on a guard that must hold permanently. If this mutual exclusion regresses, users mixing --global and --root get silent misbehavior with no diagnostic.
- [Auth Expert] Capture cwd before os.chdir and pass cwd=original to resolve_credential_from_git subprocess.run; or set GIT_DIR/GIT_WORK_TREE env vars pointing at the original repo -- Post-chdir, git credential fill runs with cwd=root. If root is outside the original git repo, per-repo .git/config credential helpers are silently skipped. This breaks private local-package resolution for any user with repo-local credential config.
- [OSS Growth Hacker] Add CHANGELOG entry for --root and a Docker multi-stage build snippet to the advanced install docs -- The flag closes a concrete issue (enhancement:
apm install --root <dir>to target a directory other than $PWD #888) and opens three adopter segments. A missing CHANGELOG entry breaks the community trust contract. The Docker snippet is the single highest-ROI conversion artifact this flag enables.
Architecture
classDiagram
direction LR
class InstallScope {
<<Enum>>
PROJECT
USER
}
class scope {
<<Module>>
-_SOURCE_ROOT_OVERRIDE Path
+set_source_root_override(path)
+get_source_root_override() Path
+get_source_root(scope) Path
+get_deploy_root(scope) Path
+get_apm_dir(scope) Path
+get_manifest_path(scope) Path
+get_lockfile_dir(scope) Path
}
class install_root_redirect {
<<ContextManager>>
+root str
+dry_run bool
+__enter__() captures cwd, chdir to root
+__exit__() chdir original, clear override
}
class compile_root_redirect {
<<Alias>>
}
class InstallContext {
<<Dataclass>>
+source_root Path
+project_root Path
+scope InstallScope
+expected_hash_change_deps set
+direct_dep_failed bool
}
class InstallPipeline {
<<Pure>>
+run(ctx) void
}
class DownloadPhase {
<<IOBoundary>>
+uses ThreadPoolExecutor
}
class install_cmd {
<<CLICommand>>
+root Path
+scope InstallScope
}
class compile_cmd {
<<CLICommand>>
+root Path
}
install_root_redirect ..> scope : calls set_source_root_override
compile_root_redirect --|> install_root_redirect : alias
install_cmd ..> install_root_redirect : wraps body
compile_cmd ..> compile_root_redirect : wraps body
InstallPipeline *-- InstallContext : reads source_root
InstallPipeline *-- DownloadPhase : spawns threads
InstallContext ..> scope : get_source_root()
DownloadPhase ..> scope : Path.cwd() in thread workers
class scope:::touched
class install_root_redirect:::touched
class compile_root_redirect:::touched
class InstallContext:::touched
class InstallPipeline:::touched
class install_cmd:::touched
class compile_cmd:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A(["apm install --root DIR"]) --> B["install_cmd: click option parsed\nsrc/apm_cli/commands/install.py"]
B --> C{"root set?"}
C -- No --> D["[FS] normal pipeline, Path.cwd()=PWD"]
C -- Yes --> E["install_root_redirect(root, dry_run)\nsrc/apm_cli/install/root_redirect.py"]
E --> F["[FS] target.mkdir(parents=True)\ncreate deploy root if missing"]
F --> G["original = Path.cwd()\ncapture source root"]
G --> H["set_source_root_override(original)\nsrc/apm_cli/core/scope.py"]
H --> I["[FS] os.chdir(root)\nprocess cwd = deploy root from here"]
I --> J["get_source_root(PROJECT) -> _SOURCE_ROOT_OVERRIDE\nreturns original PWD for apm.yml reads"]
I --> K["get_deploy_root(PROJECT) -> Path.cwd()\nreturns deploy root for writes"]
J --> L["pipeline: source_root=get_source_root()\nsrc/apm_cli/install/pipeline.py"]
K --> M["get_apm_dir -> Path.cwd()=deploy root\nlockfile, apm_modules written there"]
L --> N["[NET+THREADS] DownloadPhase ThreadPoolExecutor\nWARNING: threads share mutated cwd + global"]
N --> O["[FS] IntegratePhase\nwrites .claude/ .codex/ .agents/ to deploy root"]
O --> P{"exception?"}
P -- Yes --> Q["finally: os.chdir(original)"]
P -- No --> Q
Q --> R["finally: set_source_root_override(None)"]
R --> S(["control returned, state restored"])
Recommendation
The feature is behaviorally correct, the primary context manager has solid unit coverage (state-unwind on exception verified), and the read/write split root model is architecturally sound. The five followups above are real gaps but none is a blocking regression on the current implementation; they are regression traps and hardening items that should be filed as tracked issues and resolved in the next patch window. The path containment guard and the integration test are the two highest-urgency items and should ideally land before the first public release that includes this flag.
Full per-persona findings
Python Architect
-
[recommended] _SOURCE_ROOT_OVERRIDE global has no lock; ThreadPoolExecutor in download phase creates a race window at
src/apm_cli/core/scope.py:60
install/phases/download.py spins a ThreadPoolExecutor during the same install run that holds the chdir + _SOURCE_ROOT_OVERRIDE mutation. os.chdir() is process-wide and not thread-safe; _SOURCE_ROOT_OVERRIDE is a bare module-level global with no threading.Lock. In the current pipeline the threads only do network I/O and do not call set_source_root_override, so no race fires today. But the invariant is undocumented: nothing prevents a future phase or server-mode CLI from touching scope helpers inside a worker.
Suggested: Add threading.Lock around reads/writes in scope.py, or add a docstring warning on install_root_redirect that it must only be entered from the main thread before any ThreadPoolExecutor is live. -
[recommended] install_root_redirect is non-reentrant: nested entry silently corrupts restore path at
src/apm_cli/install/root_redirect.py:68
The context manager computes original = Path.cwd() at entry. If a caller is already inside an install_root_redirect (cwd is the deploy root), a nested entry records the deploy root as 'original', then on exit os.chdir(original) leaves the process in the deploy root. No guard exists. compile_root_redirect shares the same code.
Suggested: At the top of install_root_redirect, assert get_source_root_override() is None, or raise RuntimeError('install_root_redirect is not reentrant') when _SOURCE_ROOT_OVERRIDE is already set. -
[nit] get_lockfile_dir docstring chain is misleading about how the override is consulted at
src/apm_cli/core/scope.py:162
Docstring says it 'honours set_source_root_override via Path.cwd() after install_root_redirect chdirs'. The override is NOT consulted by get_apm_dir or get_lockfile_dir -- only the chdir makes cwd() point to the deploy root.
CLI Logging Expert
-
[recommended] No confirmation message when --root redirects writes at
src/apm_cli/install/root_redirect.py
install_root_redirect() does os.chdir(root) and set_source_root_override() with zero user-visible output. A user running 'apm install --root /tmp/scratch my-pkg' sees the normal install summary with no indication that apm_modules/ and .claude/ landed under /tmp/scratch rather than $PWD.
Suggested: Emit an [i] line at the top of install_root_redirect() before yield, e.g. _rich_info(f'[i] Deploy root: {target} (sources: {original})') when root is set and not dry_run. -
[recommended] Install summary does not name the deploy root; relative paths in summary are misleading after chdir at
src/apm_cli/commands/install.py
After os.chdir(root), any relative path the InstallLogger/CommandLogger renders looks like it belongs to $PWD. A user diffing two runs sees identical-looking summaries pointing to paths that resolve to different absolute locations.
Suggested: When root is set, append the absolute deploy root to the summary line, e.g. '[+] Installed 3 packages -> /abs/path/to/root'. -
[nit] --root/--global conflict error omits the fix at
src/apm_cli/commands/install.py
Suggested: Change to: '--root is not valid with --global (user scope). Drop --global to install into DIR, or drop --root to install into the user scope.' -
[nit] dry-run UsageError for missing root dir uses f-string path without quoting at
src/apm_cli/install/root_redirect.py
Suggested: Change to: f"--root '{target}' does not exist. Create the directory before --dry-run, or drop --dry-run to let install/compile create it."
DevX UX Expert
-
[recommended] --dry-run refuses to run when DIR does not exist, which inverts the expected dry-run contract
Every major package manager lets --dry-run run against a hypothetical state. Requiring DIR to already exist for --dry-run means a CI author must mkdir first, defeating the 'safe preview' mental model.
Suggested: For --dry-run, treat a missing DIR as a preview-only no-op (log 'would create DIR') rather than a hard error. -
[recommended] Split read/write root will produce confusing apm.yml-not-found error when invoked from the wrong directory
A user who runs 'apm install --root /tmp/verify' from a directory lacking apm.yml gets a source-resolution error with no mention of --root or $PWD. The help text documents the split, but the failure mode does not surface it.
Suggested: When source resolution fails and --root is active, prepend: 'apm.yml not found in $PWD ({cwd}). With --root, sources always resolve from $PWD, not from DIR.' -
[nit] --root vs --prefix naming: help text analogue list ('Mirrors pip install --target / npm install --prefix') must not be shortened in future edits.
-
[nit] --root is not valid with --watch error message is terse; no recovery hint.
Supply Chain Security Expert
-
[recommended] os.chdir() and _SOURCE_ROOT_OVERRIDE are process-global with no lock; concurrent callers corrupt each other at
src/apm_cli/install/root_redirect.py
root_redirect.py uses os.chdir(target) and set_source_root_override() which both mutate process-global state. Parallel pytest-xdist workers, embedded library use, or future async install flows will race: one thread's chdir stomps another's cwd.
Suggested: Add a module-level threading.Lock around the chdir+set_source_root_override pair, held for the entire duration of the context manager body. -
[recommended] --root accepts any filesystem path with no containment guard; apm install --root / silently creates agent dirs at filesystem root at
src/apm_cli/install/root_redirect.py
root_redirect.py calls target.mkdir(parents=True, exist_ok=True) on an unvalidated Path(root). No call to validate_path_segments or ensure_path_within. A user typo like --root / or --root ~ causes APM to create and populate .claude/, .codex/, apm_modules/ under / or $HOME without confirmation.
Suggested: Before mkdir, call validate_path_segments(str(target)) and warn/require --yes when target resolves to $HOME directly. -
[nit] target is not resolved before os.chdir; a symlink as --root is followed silently at
src/apm_cli/install/root_redirect.py
Suggested: Add target = Path(root).resolve() to make the physical path explicit and consistent with set_source_root_override which already calls .resolve().
OSS Growth Hacker
-
[recommended] Add a CHANGELOG entry for this feature -- it is absent from CHANGELOG.md
The --root flag opens three distinct use-case categories (scratch verification, Docker layer builds, CI isolation). A changelog entry is the minimum for issue enhancement:apm install --root <dir>to target a directory other than $PWD #888 close visibility.
Suggested: 'apm install --root DIR redirects apm_modules/, lockfile, and all runtime deploy dirs under a custom root while sources resolve from $PWD -- enables Docker multi-stage builds, CI scratch-dir verification, and fixture generation. (closes enhancement:apm install --root <dir>to target a directory other than $PWD #888)' -
[recommended] Add a Docker multi-stage build snippet to the advanced install docs -- highest-ROI conversion surface this flag enables
A three-line Dockerfile snippet (apm install --root /layer && COPY /layer .) makes this flag immediately actionable for CI/Docker adopters. -
[nit] --root naming: add a one-line callout in docs pointing to pip --target / npm --prefix analogues.
-
[nit] Tweet-length hook is not written anywhere: 'apm install --root /tmp/env -- test your agent setup in total isolation, like pip install --target but for AI agents.'
Auth Expert
- [recommended] git credential fill inherits post-chdir cwd, losing local .git/config credential helper config at
src/apm_cli/core/token_manager.py
install_root_redirect calls os.chdir(root) before yielding. AuthResolver resolves credentials lazily, so any try_with_fallback call that reaches git credential fill runs with cwd=root. If root is outside the original git repo, git will not find the local .git/config credential.helper entry, silently skipping per-repo credential helpers.
Suggested: Capture the original cwd before chdir and pass cwd=original to the subprocess.run call in resolve_credential_from_git, or set GIT_DIR/GIT_WORK_TREE env vars pointing at the original repo.
Doc Writer
-
[nit] linting.instructions.md is missing the applyTo frontmatter field that tests.instructions.md uses for scope binding at
.apm/instructions/linting.instructions.md
Suggested: Add applyTo: '**/*.py' if intent is Python sources only, or document global scope is deliberate. -
[nit] SKILL.md doc-writer activation list does not include .apm/instructions/** as a trigger path at
.apm/skills/apm-review-panel/SKILL.md
Suggested: Add .apm/instructions/**/*.md to the Doc Writer activation file list. -
[nit] cli-commands.md --root description lists .opencode/ but apm-usage commands.md entry omits it at
packages/apm-guide/.apm/skills/apm-usage/commands.md
Suggested: Expand the --root DIR description to enumerate the same directories as cli-commands.md.
Test Coverage Expert
-
[recommended] --global + --root mutual exclusion (UsageError) has no executable test at
src/apm_cli/commands/install.py
src/apm_cli/commands/install.py raises click.UsageError but no test invokes the install command with both flags and asserts the error. If this guard regresses, users mixing --global and --root get silent misbehavior.
Proof (missing at unit):tests/unit/install/test_root_redirect.py::test_global_and_root_are_mutually_exclusive-- proves: apm install --global --root DIR must exit non-zero with a clear error message [devx]
with pytest.raises(click.UsageError, match='not valid with --global'): runner.invoke(install_cmd, ['--global', '--root', str(tmp_path)]) -
[recommended] No integration-with-fixtures test exercises apm install --root DIR end-to-end deploy path at
src/apm_cli/commands/install.py
The core user promise of --root is that apm_modules/, lockfile, and runtime dirs land under DIR while sources resolve from $PWD. This cross-module flow (resolve->integrate->lockfile write) cannot be verified by unit tests on root_redirect.py alone. Grepped tests/integration/ for '--root', 'root_redirect', 'source_root': no match on install flow.
Proof (missing at integration-with-fixtures):tests/integration/test_install_root_e2e.py::test_install_root_writes_outputs_under_root_dir-- proves: apm install --root DIR writes apm_modules/, apm.lock.yaml, and runtime dirs under DIR; sources resolve from $PWD [portability-by-manifest,devx]
assert (root_dir / 'apm_modules').exists(); assert not (pwd / 'apm_modules').exists() -
[nit] Unit tests for root_redirect pass; state-unwind on exception is covered at
tests/unit/install/test_root_redirect.py
Proof (passed at unit):tests/unit/install/test_root_redirect.py::test_restore_on_exception-- proves: install_root_redirect clears source_root override and restores cwd even when an exception propagates [devx]
assert Path.cwd() == cwd_before\nassert get_source_root_override() is None
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Warning
Firewall blocked 1 domain
The following domain was blocked by the firewall during workflow execution:
pypi.org
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "pypi.org"See Network Configuration for more information.
Note
🔒 Integrity filter blocked 2 items
The following items were blocked because they don't meet the GitHub integrity level.
- #928
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved". - feat(install, compile):
--root DIRto redirect writes while sources resolve from $PWD #928pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved".
To allow these resources, lower min-integrity in your GitHub frontmatter:
tools:
github:
min-integrity: approved # merged | approved | unapproved | noneGenerated by PR Review Panel for issue #928 · ● 5.3M · ◷
Closes #888.
Downstream consumer validating this PR end-to-end: juspay/kolu#716 — collapses a ~60-line
rsync+mkdir+cpscratch-staging workaround in their CI pipeline to a four-lineapm install --root+apm compile --rootsequence.Why
apm installandapm compilealways write relative to$PWD, which forces any scratch-directory workflow to physically stage every input file (manifest,.apm/, lockfile, project tree) beforecd-ing in. The juspay/kolu CI pipeline hit this in a sharp way (#684): runningapm installon the live worktree was briefly destroying.claude/while a Claude Code session had an open file handle there. The workaround exists purely because there was no--target-style flag.This PR adds that flag:
apm install --root DIRandapm compile --root DIR. Writes go underDIR; sources (apm.yml,.apm/, local-path packages, the project tree used for distributed compile placement scoring) continue reading from$PWD. Semantics deliberately mirrorpip install --targetandnpm install --prefix.With both flags in place the kolu workaround collapses to:
How
The implementation picks a chdir + source-root pin model over refactoring the long tail of
Path.cwd()/os.getcwd()call sites (notably the MCP adapters inapm_cli.adapters.client.*, the OpenCode/Cursor/VSCode adapters, andmcp_integrator.py). A new context managerapm_cli.install.root_redirect.install_root_redirectdoes two things:os.chdir(root)so every site that hardcodesPath.cwd()auto-resolves to the deploy root.set_source_root_override($PWD)(process-global) so helpers that need the user's source tree (get_source_root,get_manifest_path) still point at the original working directory.Both are restored in a
finallyso the context doesn't leak across invocations — important for test runners, REPL sessions, and embedded callers.compile_root_redirectis a one-line alias for the same implementation.get_manifest_pathis decoupled fromget_apm_dir: the manifest is a source, so it followsget_source_root, while the lockfile /apm_modules// deploy dirs followget_deploy_root. That distinction is the design crux — the issue body explicitly called out "sources continue resolving from $PWD" as the intended semantics.Install side
InstallContextgainssource_root(defaults toproject_rootfor back-compat via__post_init__).install/pipeline.pycomputessource_root = get_source_root(scope)alongsideproject_root = get_deploy_root(scope)._project_has_root_primitives(scans source root for.apm/),integrate_local_content's synthetic_localpackage (install_path = source_root), the dep resolver'sapm.ymllookup, and local-path package resolution inphases/resolve.py+sources.py.The resolver fix is its own commit because the tree-shaped symptom ("running
apm install --rootsilently resolved zero direct dependencies") is worth a standalone reviewable change.Compile side
AgentsCompilerandDistributedAgentsCompilergain an optionalsource_dirparameter (defaults tobase_dirfor back-compat).base_dirkeeps driving placement targets and write paths;source_dirdrives primitive discovery,ContextOptimizer, and constitution lookup.DistributedAgentsCompiler._source_to_basetranslates placement-map keys from source-rooted to base-rooted so the AGENTS.md writes land at the deploy root even though the placement decisions were scored against the source tree.template_builder.build_conditional_sectionspicks up a matchingsource_dirparameter so<!-- Source: ... -->display paths render relative to$PWD— without this, scratch-compiled output diverges from in-place output by embedding absolute scratch paths.What's included / what's not
Source resolution for
apm.ymlis tied toget_source_root, which currently means the original CWD. Adding a separate--manifest PATHflag for reading the manifest from an arbitrary location (called out as future work in the issue body) is deliberately not in this PR — the current design accommodates it cleanly by havingget_manifest_pathconsult an additional override, but that's a future increment.The compiler's
source_diris CLI-aware by necessity because distributed-compile placement scoring scans the project tree; a pre-resolved path from the caller would be fine except thatContextOptimizeris constructed insideDistributedAgentsCompiler.__init__and already took a directory. Threading two paths through is the minimal change.Verification
tests/unit/install/(63 tests) +tests/unit/compilation/+tests/unit/commands/(344 tests combined) all pass on this branch.tests/unit/suite passes (5430 / 5430 excluding 4 environment-dependent failures unrelated to this change:test_is_tool_available("python")and three_real_systemchecks — none reference any code I touched).install.pyLOC budget intest_architecture_invariants.pybumps 1700 → 1725 with rationale in the same comment-based tradition as prior PRs (Enforce apm-policy.yml atapm installtime, not only inapm audit --ci#827, feat(policy): enforce apm-policy.yml at install time #832, Azure DevOps authentication via Entra ID (AAD) bearer tokens #852, feat(auth): Azure DevOps authentication via Entra ID (AAD) bearer tokens #856); the pending--mcpextraction that's already tracked in that test will recover the budget.End-to-end validated against juspay/kolu#716:
apm audit --cipasses 7/7 and every AGENTS.md output (root +packages/+packages/client/src/+packages/tests/features/+packages/server/src/) is byte-identical between anapm install --root "$scratch" && apm compile --root "$scratch"run and the in-place equivalent.Draft status
Drafted so the design can be sanity-checked before it leaves draft. In particular, feedback welcome on:
Path.cwd()long tail is real and this PR sidesteps it; the alternative is a sweep through the adapters to use scope helpers, which is a larger and probably-separate change. Is that acceptable?source_dirleak into the compiler classes. Principled ("the compiler already knew about roots") or a smell ("CLI-level concept leaking into a pure library")? I picked principled-enough-for-now.🤖 Generated with Claude Code