Skip to content

Enhance CLI wrap functionality and streamline install options - #141

Merged
Minitour merged 4 commits into
version-2.0from
feat/agent-wrap
Jul 31, 2026
Merged

Enhance CLI wrap functionality and streamline install options#141
Minitour merged 4 commits into
version-2.0from
feat/agent-wrap

Conversation

@Minitour

Copy link
Copy Markdown
Member

This pull request introduces several improvements and new features to the CLI, focusing on enhanced workspace handling, better process control, and improved testability. The main highlights are the addition of a new wrap command for workspace management, more flexible and testable install logic, and new tests for the add command's install behavior.

Key changes include:

New Features: Wrap Workspace Support

  • Added a new wrapCommand in wrap.ts to manage provider-specific wrap workspaces, including workspace preparation, watcher management, and launching providers in both GUI and CLI modes. This includes robust process and signal handling for cleanup and user interrupts.
  • Integrated wrap workspace support into the CLI entrypoint (index.ts), including a new __wrap_watch__ mode for detached watcher processes. [1] [2]

Install Command Refactoring and Options

  • Refactored installCommand to accept additional options (projectPath, identityPath, exitProcess, quiet) for improved testability and better integration with wrap workspaces. The install logic is now encapsulated in a helper function for clearer process control and error handling. [1] [2] [3] [4] [5] [6] [7] [8] [9]
  • Added a check to refuse running install and clean commands inside a wrap workspace, improving safety and preventing accidental modifications. [1] [2] [3]

Add Command Enhancements

  • Updated the addCommand to support an --install flag, allowing users to optionally trigger installation after updating the capabilities file. The install step is now conditional and provides user feedback. [1] [2] [3] [4] [5]

Test Coverage

  • Added a new test suite for the addCommand to verify the correct behavior of the --install flag, ensuring that installation is only triggered when requested.

These changes collectively improve the CLI's robustness, extensibility, and testability, especially around workspace management and install workflows.

Minitour and others added 3 commits July 31, 2026 00:05
Run providers from a persistent CAPA workspace without modifying in-repo configs, with scoped symlink exclusions, GUI wait-until-close, and stop killing active wraps.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduces a new detached process for the wrap command, allowing for better handling of interactive providers without console input conflicts. The wrap watcher runs in a separate process, ensuring that the main CLI can spawn the provider without interference. Additionally, updates to the launch process now utilize spawnSync for improved TTY handling and reliability.

- Added `__wrap_watch__` command to start the wrap watcher.
- Implemented `startDetachedWatchWorker` to manage the detached process.
- Updated `launchProvider` to use spawnSync for CLI interactions.
- Enhanced PID detection to include wrap watchers.
- Simplified the console output for the capabilities file update in the add command.
- Introduced a `quiet` option in the install command to suppress UI output.
- Updated the install command to manage the `quiet` flag state effectively.
- Enhanced server management to conditionally log server status based on the `quiet` setting.
- Adjusted the capabilities debounce time for improved performance during live re-apply operations.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add capa wrap shadow workspace command and refactor install for testability

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Adds a new capa wrap command that runs providers from a persistent shadow workspace (symlinked
 mirror of the project) without touching in-repo provider configs, with GUI/CLI launch modes, live
 capabilities re-apply, and bidirectional file sync.
• Refactors installCommand to accept projectPath, identityPath, exitProcess, and quiet
 options so it can be invoked programmatically from wrap and tests instead of always calling
 process.exit.
• Adds --install flag to addCommand and guards install/clean from running inside a wrap
 workspace; capa stop now also terminates active wrap sessions.
• Adds test coverage for the add command's install flag, wrap workspace preparation, symlink sync,
 CLI launch, and process discovery/session teardown.
Diagram

graph TD
  CLI["capa CLI index.ts"] --> WrapCmd["wrapCommand"] --> Workspace["prepareWorkspace"] --> Symlinks[("Shadow Workspace")]
  WrapCmd --> Launch["launchProvider"] --> ProviderBin{{"Provider Binary"}}
  WrapCmd --> Watcher["wrap watch worker"] --> Symlinks
  Workspace --> InstallCmd["installCommand"] --> DB[("CapaDatabase")]
  CLI --> AddCmd["addCommand --install"] --> InstallCmd
  CLI --> CleanCmd["cleanCommand"] --> Marker["refuseIfWrapWorkspace"]
  subgraph Legend
    direction LR
    _svc(["Service/Command"]) ~~~ _db[("Data Store")] ~~~ _ext{{"External Binary"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Bind mount / overlay filesystem
  • ➕ True filesystem-level isolation without per-entry symlink bookkeeping
  • ➕ Avoids manual promote/sync logic entirely
  • ➖ Not available on Windows without WSL
  • ➖ Requires elevated privileges or kernel modules
  • ➖ Much higher implementation complexity than symlinks
2. Git worktree for the shadow workspace
  • ➕ Built-in tooling, avoids custom symlink/promote logic
  • ➕ Naturally isolates git state
  • ➖ Does not include untracked or gitignored files (e.g. node_modules, .env) that providers/agents may need
  • ➖ Still requires custom logic to shadow provider-owned config paths

Recommendation: The symlink-based shadow workspace with a polling+fs.watch reconciliation loop is a reasonable pragmatic choice given cross-platform constraints (Windows lacks reliable recursive fs.watch and symlink privileges vary). An alternative like a FUSE/overlay filesystem would be more robust but is not cross-platform-friendly and adds a heavy dependency; a git-worktree-based approach was likely dismissed because it doesn't cleanly handle untracked/gitignored files that the top-level symlink strategy handles naturally. The chosen approach (symlink real files, exclude provider-owned paths, poll as a Windows fallback) is appropriate for the goal of not modifying in-repo configs while still supporting Windows via junctions/hardlinks.

Files changed (25) +2406 / -34

Enhancement (17) +1843 / -22
add.tsAdd --install flag support and wrap workspace guard to addCommand +23/-6

Add --install flag support and wrap workspace guard to addCommand

• Introduces an --install option that conditionally triggers installCommand after updating capabilities, replaces direct install calls with a maybeInstall helper, and refuses to run inside a wrap workspace via refuseIfWrapWorkspace.

src/cli/commands/add.ts

clean.tsRefuse clean command inside wrap workspace +5/-0

Refuse clean command inside wrap workspace

• Adds a guard that exits early if cleanCommand is invoked from inside a wrap shadow workspace.

src/cli/commands/clean.ts

context.tsExtend InstallOptions with projectPath, identityPath, exitProcess, quiet +17/-0

Extend InstallOptions with projectPath, identityPath, exitProcess, quiet

• Adds new optional fields to InstallOptions to support wrap workspaces writing into a shadow path while preserving real project identity, non-exiting error handling, and quiet UI output.

src/cli/commands/install-tasks/context.ts

wrap.tsNew wrapCommand implementation for shadow provider workspaces +212/-0

New wrapCommand implementation for shadow provider workspaces

• Implements the wrap command: prepares the shadow workspace, starts watchers (in-process for GUI, detached worker for CLI), launches the provider binary, and handles SIGTERM/SIGHUP/interrupt cleanup and workspace pruning.

src/cli/commands/wrap.ts

index.tsWire wrap command and detached watcher entrypoint into CLI +26/-1

Wire wrap command and detached watcher entrypoint into CLI

• Registers the new 'wrap' subcommand with --project/--print-dir/--prune options, adds a hidden __wrap_watch__ entrypoint for the detached watcher process, adds --install to the add command, and updates the stop command description.

src/cli/index.ts

server-manager.tsSupport quiet output and stop active wrap sessions on server stop +29/-15

Support quiet output and stop active wrap sessions on server stop

• Suppresses server start/status logs when quiet flag is set and calls stopAllWrapSessions when stopping the server so 'capa stop' also terminates active wrap processes.

src/cli/utils/server-manager.ts

launch.tsNew launchProvider helper for CLI/GUI provider launch +135/-0

New launchProvider helper for CLI/GUI provider launch

• Adds logic to launch CLI providers via blocking spawnSync with inherited TTY stdio (releasing parent stdin and ignoring SIGINT) and GUI providers via detached Bun.spawn with a closed/kill interface.

src/cli/utils/wrap/launch.ts

marker.tsAdd wrap workspace marker detection and command refusal helper +54/-0

Add wrap workspace marker detection and command refusal helper

• Adds readWorkspaceMarker to detect if the cwd is inside a wrap shadow workspace and refuseIfWrapWorkspace to block mutating commands from running there.

src/cli/utils/wrap/marker.ts

sessions.tsAdd process discovery/termination for live wrap sessions +107/-0

Add process discovery/termination for live wrap sessions

• Implements cross-platform (ps/PowerShell) scanning for capa processes running 'wrap' or '__wrap_watch__', plus graceful SIGTERM/SIGKILL termination used by 'capa stop'.

src/cli/utils/wrap/sessions.ts

symlink-workspace.tsAdd symlink workspace build, sync, and promote-to-real logic +296/-0

Add symlink workspace build, sync, and promote-to-real logic

• Implements creation of top-level symlinks/junctions from the real project into the shadow workspace, scoped exclusion sets per provider, and promotion of workspace-only files/directories back into the real project with link fallback.

src/cli/utils/wrap/symlink-workspace.ts

wait-for-interrupt.tsAdd cross-platform interrupt waiting helper +73/-0

Add cross-platform interrupt waiting helper

• Provides waitForInterrupt to block until SIGINT/SIGTERM/SIGHUP, an abort signal, or a typed 'q'/'quit'/'exit' readline command, with extra Windows reliability via readline.

src/cli/utils/wrap/wait-for-interrupt.ts

watch-project.tsAdd bidirectional file watcher and capabilities live re-apply +412/-0

Add bidirectional file watcher and capabilities live re-apply

• Implements fs.watch + polling reconciliation to keep the real project and shadow workspace in sync (creates, updates, deletes) and debounced re-install when capabilities.yaml changes.

src/cli/utils/wrap/watch-project.ts

watch-worker.tsAdd detached watcher process entrypoint +59/-0

Add detached watcher process entrypoint

• Implements the __wrap_watch__ subprocess entrypoint that starts the wrap watchers and stays alive until signaled, used so the main CLI process can block on spawnSync without racing stdin.

src/cli/utils/wrap/watch-worker.ts

workspace.tsAdd prepareWorkspace to create/reuse fingerprinted shadow workspaces +262/-0

Add prepareWorkspace to create/reuse fingerprinted shadow workspaces

• Implements cold/warm workspace resolution keyed by a capabilities fingerprint, writes a workspace marker, migrates legacy flat cache layouts, and exposes pruneWorkspaces for cleanup.

src/cli/utils/wrap/workspace.ts

index.tsAdd wrap provider lookup and owned-path exclusion helpers +95/-0

Add wrap provider lookup and owned-path exclusion helpers

• Adds getWrappableProviders/getWrappableProvider, resolveProviderId, collectWrapExclusionProviderIds, and getProviderOwnedTopLevelNames to support scoping the wrap symlink exclusion set to relevant providers.

src/shared/providers/index.ts

paths.tsAdd workspace marker constant, path helper, and type +19/-0

Add workspace marker constant, path helper, and type

• Introduces WORKSPACE_MARKER filename, getWorkspacesDir(), and the WorkspaceMarker interface used to identify and validate wrap shadow workspaces.

src/shared/workspaces/paths.ts

providers.tsAdd WrapIntegration type and wrap field to ProviderIntegration +19/-0

Add WrapIntegration type and wrap field to ProviderIntegration

• Defines the WrapIntegration interface (binary, kind, args) and adds an optional wrap field to ProviderIntegration so providers can opt into 'capa wrap' support.

src/types/providers.ts

Refactor (1) +72 / -12
install.tsRefactor installCommand for testability and wrap integration +72/-12

Refactor installCommand for testability and wrap integration

• Splits installCommand into a thin wrapper and an installCommandBody helper supporting projectPath/identityPath/exitProcess/quiet options, replaces process.exit calls with a failExit helper that can throw instead, and refuses running inside a wrap workspace unless an explicit projectPath was passed.

src/cli/commands/install.ts

Tests (6) +488 / -0
add-install-flag.test.tsAdd tests for addCommand --install flag behavior +65/-0

Add tests for addCommand --install flag behavior

• New test suite verifying that installCommand is only called when the --install flag is passed to addCommand, using a mocked install module and isolated home/project directories.

src/cli/commands/tests/add-install-flag.test.ts

launch.test.tsAdd tests for CLI provider launch via spawnSync +44/-0

Add tests for CLI provider launch via spawnSync

• Verifies launchProvider uses spawnSync with inherited stdio and correct args/options for CLI-kind providers, using a mocked child_process module.

src/cli/utils/wrap/tests/launch.test.ts

sessions.test.tsAdd tests for wrap session process discovery and teardown +21/-0

Add tests for wrap session process discovery and teardown

• Tests isPidRunning, findWrapPids, and stopAllWrapSessions helpers used to discover and stop live wrap processes.

src/cli/utils/wrap/tests/sessions.test.ts

symlink-workspace.test.tsAdd tests for symlink workspace build/sync/promote logic +162/-0

Add tests for symlink workspace build/sync/promote logic

• Covers exclusion set scoping, top-level symlink creation, promotion of workspace-only files back to the real project, and removal of top-level entries.

src/cli/utils/wrap/tests/symlink-workspace.test.ts

workspace.test.tsAdd tests for prepareWorkspace cold/warm behavior and fingerprinting +130/-0

Add tests for prepareWorkspace cold/warm behavior and fingerprinting

• Verifies cold workspace creation triggers install exactly once, warm reuse skips install when the DB already has the project, and capabilities fingerprint changes when capabilities.yaml changes.

src/cli/utils/wrap/tests/workspace.test.ts

wrap.test.tsAdd tests for wrap provider registry helpers +66/-0

Add tests for wrap provider registry helpers

• Tests getWrappableProvider(s), collectWrapExclusionProviderIds, and getProviderOwnedTopLevelNames for correct scoping and alias resolution.

src/shared/providers/tests/wrap.test.ts

Other (1) +3 / -0
registry.tsDeclare wrap integration for claude-code, codex, and cursor providers +3/-0

Declare wrap integration for claude-code, codex, and cursor providers

• Adds wrap metadata (binary, kind, args) to the claude-code, codex, and cursor provider entries so they become wrappable via 'capa wrap'.

src/shared/providers/registry.ts

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Wrap marker misses subdirs ✓ Resolved 🐞 Bug ≡ Correctness
Description
readWorkspaceMarker() only checks the current directory and its immediate parent for the wrap
marker, so running capa install/add/clean from a subdirectory inside a wrap workspace will not be
refused. This bypasses the new safety guard and allows project-mutating commands to run from within
a wrap workspace tree.
Code

src/cli/utils/wrap/marker.ts[R22-39]

+export async function readWorkspaceMarker(
+  dir: string = process.cwd(),
+): Promise<WorkspaceMarker | null> {
+  const abs = resolve(dir);
+
+  // Primary: parent cache root (marker is one level above the working dir).
+  const parentMarker = await tryReadMarker(join(abs, '..', WORKSPACE_MARKER));
+  if (parentMarker) {
+    const expected =
+      parentMarker.workingDir ?? basename(resolve(parentMarker.realProjectPath));
+    if (basename(abs) === expected || !parentMarker.workingDir) {
+      return parentMarker;
+    }
+  }
+
+  // Legacy / direct open of the cache root itself.
+  return tryReadMarker(join(abs, WORKSPACE_MARKER));
+}
Evidence
The marker lookup only checks join(abs,'..',WORKSPACE_MARKER) and join(abs,WORKSPACE_MARKER),
which won’t find the marker when cwd is deeper than one level under the nested working dir; multiple
commands depend on this check at startup and will therefore run inside wrap workspaces when invoked
from subdirectories.

src/cli/utils/wrap/marker.ts[16-39]
src/cli/commands/add.ts[369-384]
src/cli/commands/clean.ts[16-20]
src/cli/commands/install.ts[71-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`readWorkspaceMarker()` fails to detect wrap workspaces when the current working directory is *inside* the nested working directory (e.g. `<cache>/<project>/src`). It only checks `<cwd>/..` and `<cwd>` for `.capa-workspace.json`, so `refuseIfWrapWorkspace()` can return false from subdirectories.
## Issue Context
Commands like `add`, `clean`, and (optionally) `install` rely on `refuseIfWrapWorkspace()` to prevent running mutating operations inside a wrap workspace.
## Fix Focus Areas
- src/cli/utils/wrap/marker.ts[16-39]
- src/cli/commands/add.ts[369-384]
- src/cli/commands/clean.ts[16-20]
- src/cli/commands/install.ts[71-90]
### Suggested fix approach
Walk up the directory tree from `dir` to the filesystem root and:
1) Check for a marker at each ancestor (cache root case: `<ancestor>/.capa-workspace.json`).
2) Also support the nested layout by checking `<ancestor>/..` as needed, but the simplest is: at each step, attempt to read `<ancestor>/.capa-workspace.json` and validate that the current `dir` is under `<ancestor>/<workingDir>` (or, for legacy markers without `workingDir`, treat `<ancestor>` as the workspace root).
3) Add a regression test where cwd is a subdirectory under the nested working dir and ensure `refuseIfWrapWorkspace()` returns true.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Relink deletes on failure ✓ Resolved 🐞 Bug ☼ Reliability
Description
tryRelink() deletes the workspace entry before attempting to create the symlink/hardlink, but on
link failure it returns false without restoring the deleted entry. This contradicts the function’s
contract/comments and can delete workspace files/dirs (and for directories, lose unmirrored changes)
when linking fails (e.g., Windows cross-volume hardlink or no symlink privilege).
Code

src/cli/utils/wrap/symlink-workspace.ts[R175-187]

+function tryRelink(realEntry: string, wsEntry: string): boolean {
+  try {
+    if (existsSync(wsEntry)) {
+      // Only remove if not already the link we want
+      if (isAlreadyLinked(wsEntry, realEntry)) return true;
+      rmSync(wsEntry, { recursive: true, force: true });
+    }
+    createWorkspaceSymlink(realEntry, wsEntry);
+    return true;
+  } catch {
+    return false;
+  }
+}
Evidence
tryRelink() removes wsEntry before calling createWorkspaceSymlink() and returns false on
error, meaning the workspace entry is not preserved despite the contract. promoteToRealProject()
relies on this behavior and even comments that it will not delete workspace content without linking
/ will keep the workspace file, which is not true with the current implementation.

src/cli/utils/wrap/symlink-workspace.ts[171-187]
src/cli/utils/wrap/symlink-workspace.ts[218-233]
src/cli/utils/wrap/symlink-workspace.ts[254-259]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`tryRelink()` currently `rmSync()`s `wsEntry` before creating the new link, and if link creation fails, the workspace entry is already gone. Callers and inline comments assume the workspace entry is preserved on failure.
## Issue Context
This function is used by `promoteToRealProject()` during wrap sync. On failure (common cases: Windows without Developer Mode and workspace/real on different volumes), it can remove the workspace copy unexpectedly, breaking wrap UX and potentially losing directory changes.
## Fix Focus Areas
- src/cli/utils/wrap/symlink-workspace.ts[171-187]
- src/cli/utils/wrap/symlink-workspace.ts[218-233]
- src/cli/utils/wrap/symlink-workspace.ts[254-259]
### Suggested fix approach
- Avoid deleting the existing workspace entry until after a link is successfully created.
- Example pattern: create link at a temporary path first (e.g. `wsEntry + '.tmp-link'`), then swap/rename atomically; or capture a backup copy/rename the original aside and restore it on failure.
- For the directory case where both real and workspace dirs exist, do not remove the workspace dir unless the relink succeeded (or explicitly mirror workspace content to real before replacing).
- For the file case where real exists, ensure a failed relink leaves the original workspace file in place (since callers assume that).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Wrap worker may exit ✓ Resolved 🐞 Bug ☼ Reliability
Description
The detached wrap watch worker can terminate even though it "awaits forever" because the polling
interval is unref()’d and all fs.watch() failures are swallowed. If watcher creation fails and
there are no other referenced handles, the worker can exit silently and wrap loses sync.
Code

src/cli/utils/wrap/watch-project.ts[R364-367]

+  pollTimer = setInterval(reconcile, POLL_MS);
+  if (typeof pollTimer === 'object' && pollTimer && 'unref' in pollTimer) {
+    (pollTimer as NodeJS.Timeout).unref?.();
+  }
Evidence
fs.watch() failures are intentionally ignored, so watchers may be absent; the poll timer is
explicitly unref’d, and the worker’s never-resolving Promise does not keep the event loop open on
its own. Together this can cause the worker to exit without maintaining wrap synchronization.

src/cli/utils/wrap/watch-project.ts[324-367]
src/cli/utils/wrap/watch-worker.ts[40-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The wrap watch worker intends to stay alive indefinitely, but:
- `startWrapWatchers()` unrefs its polling interval.
- All `fs.watch()` registrations are wrapped in try/catch and can result in zero active watchers.
- `await new Promise(() => {})` does not itself keep the event loop alive.
This combination can let the detached worker exit immediately in environments where `fs.watch()` fails.
## Issue Context
Wrap relies on this worker to keep symlinks and capabilities in sync while the CLI provider runs via `spawnSync`.
## Fix Focus Areas
- src/cli/utils/wrap/watch-project.ts[324-367]
- src/cli/utils/wrap/watch-worker.ts[40-59]
### Suggested fix approach
- Do not `unref()` the poll timer when running in the detached worker (or add an option to control unref behavior and keep it referenced in the worker).
- Alternatively, if all `fs.watch()` setups fail, keep a referenced interval or explicitly log/exit with an error so failures are visible.
- Consider adding a test that mocks `fs.watch` to throw and asserts the worker stays alive (or reports failure) rather than exiting silently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/cli/utils/wrap/marker.ts
Comment thread src/cli/utils/wrap/symlink-workspace.ts
Comment thread src/cli/utils/wrap/watch-project.ts
…rker

Walk ancestors for wrap markers, restore workspace entries if relink fails, and keep the detached watcher's poll timer referenced.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Minitour
Minitour merged commit 90b97e7 into version-2.0 Jul 31, 2026
@Minitour
Minitour deleted the feat/agent-wrap branch July 31, 2026 09:59
@Minitour Minitour mentioned this pull request Aug 2, 2026
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant