diff --git a/openspec/changes/add-pip-package-manager/.openspec.yaml b/openspec/changes/add-pip-package-manager/.openspec.yaml new file mode 100644 index 0000000..40cc12f --- /dev/null +++ b/openspec/changes/add-pip-package-manager/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-12 diff --git a/openspec/changes/add-pip-package-manager/design.md b/openspec/changes/add-pip-package-manager/design.md new file mode 100644 index 0000000..9f81448 --- /dev/null +++ b/openspec/changes/add-pip-package-manager/design.md @@ -0,0 +1,66 @@ +## Design + +pip should fit the existing managed installer abstraction instead of introducing a separate Python-specific lifecycle path. A pip install method is managed because Quantex can invoke deterministic install, update, and uninstall commands for a named Python package. + +## Decisions + +### 1. Model pip as a managed install type + +Add `pip` to `ManagedInstallType` and the installer capability table with: + +- `canInstall: true` +- `canUpdate: true` +- `canUninstall: true` +- `canLookupLatestVersion: false` + +pip package version lookup via PyPI API is intentionally out of scope for this change. The initial implementation focuses on reliable install/update/uninstall operations without adding PyPI registry queries. + +### 2. Use pip's standard global commands + +The pip installer executes: + +- install: `pip install ` +- update: `pip install --upgrade ` +- uninstall: `pip uninstall -y ` + +All commands use the `pip` binary detected in PATH. If `pip` is not available, the fallback detection tries `pip3` and then `python -m pip`. + +### 3. Keep package metadata explicit + +Add optional `packages.pip` metadata and a `pipInstall(packageName?: string)` helper. `getManagedPackageName` resolves in this order: + +1. method-level `packageName` +2. installer-specific package metadata such as `packages.pip` +3. npm metadata only for npm-compatible installers (`npm` and `bun`) + +This avoids accidentally using an npm package name as a pip package name. + +### 4. Do not make pip a default package manager + +The `defaultPackageManager` config remains scoped to `bun` and `npm`. pip is not a general substitute for npm-compatible install methods and should only be selected when an agent definition explicitly offers a pip method. + +### 5. Cross-platform pip detection + +pip availability detection follows this order: + +1. Try `pip --version` +2. Try `pip3 --version` +3. Try `python -m pip --version` + +Once a working pip command is found, it is used for all subsequent operations in that session. This approach handles common scenarios: + +- Systems where only `pip3` is available (common on macOS/Linux) +- Systems where pip is only available as a Python module +- Systems with both `pip` and `pip3` available + +## Non-goals + +- Add Quantex self-upgrade through pip. +- Add PyPI latest-version lookup. +- Infer pip-managed installs from arbitrary binary paths. +- Manage Python virtual environments, pyenv, asdf, or uv tool installs. + +## Risks + +- pip may require elevated permissions on some systems for global installs. The current implementation does not add `--user` flag automatically; users should ensure their pip configuration supports global installs or use `--user` flag externally. +- Some packages may have system-level dependencies that pip cannot resolve. This is acceptable because Quantex focuses on the package manager lifecycle, not system dependency resolution. diff --git a/openspec/changes/add-pip-package-manager/proposal.md b/openspec/changes/add-pip-package-manager/proposal.md new file mode 100644 index 0000000..5eeb8af --- /dev/null +++ b/openspec/changes/add-pip-package-manager/proposal.md @@ -0,0 +1,39 @@ +## Why + +Some lifecycle-managed coding agents are distributed as Python packages via pip (e.g., Mistral Vibe). Quantex currently has managed installers for Bun, npm, Homebrew, Cargo, and winget, but it cannot model or execute a first-class pip install path. Python-based agent CLIs are therefore represented as unmanaged binary command hints instead of managed lifecycle methods. + +This work is OpenSpec-required because it changes agent catalog install-method metadata, managed lifecycle execution, diagnostic output, and batch update planning. + +## What Changes + +- Add `pip` as a managed package-manager install type. +- Add `pip` to `AgentPackageMetadata` for agent definitions. +- Detect whether `pip` is available in `PATH`, with fallback to `python -m pip` detection. +- Execute pip-managed install, update, batch update, and uninstall operations through pip commands. +- Render pip install guidance in resolve/exec/list/info surfaces that already expose install methods. +- Include pip availability in `capabilities` and `doctor` diagnostics. +- Migrate verified pip-based agent definitions (starting with Mistral Vibe) from unmanaged `binaryInstall(...)` to the new managed `pipInstall(...)` method. +- Keep Quantex self-upgrade and the `defaultPackageManager` configuration scoped to the existing supported self-install sources; pip is an agent lifecycle installer, not a Quantex self-upgrade provider. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `agent-catalog`: Supported agent entries may declare pip managed install methods and package metadata. +- `agent-update`: pip-managed installs participate in managed install, update, batch update, uninstall, and diagnostic planning. + +## Impact + +- Affected code: `src/agents/types.ts`, `src/agents/methods.ts`, `src/package-manager/`, `src/utils/detect.ts`, `src/commands/capabilities.ts`, `src/commands/doctor.ts`, agent definitions, and related tests. +- Affected structured output: `quantex capabilities --json` and `quantex doctor --json` installer maps gain a `pip` key. +- No new runtime dependency is required. + +## Cross-Platform Considerations + +- Windows: Use `pip` command directly or `python -m pip` as fallback. +- macOS/Linux: Use `pip` command directly or `python -m pip` as fallback. Some systems may require `pip3` instead of `pip`. +- The pip command detection will try `pip`, then `pip3`, then `python -m pip` as fallback strategies. diff --git a/openspec/changes/add-pip-package-manager/specs/agent-catalog/spec.md b/openspec/changes/add-pip-package-manager/specs/agent-catalog/spec.md new file mode 100644 index 0000000..c86095a --- /dev/null +++ b/openspec/changes/add-pip-package-manager/specs/agent-catalog/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: pip install methods MUST be supported lifecycle metadata + +Quantex SHALL allow supported agent catalog entries to declare pip-managed install methods and pip package metadata when an upstream agent is distributed as a Python package. + +#### Scenario: Registering pip package metadata + +- **WHEN** Quantex defines or updates a supported agent entry that is distributed as a Python package +- **THEN** the entry can identify the package through `packages.pip` +- **AND** the entry can include pip managed install methods on platforms where the package is supported +- **AND** pip package metadata is treated as lifecycle metadata, not descriptive marketing copy + +#### Scenario: Rendering pip install guidance + +- **WHEN** Quantex renders install methods for an agent with a pip managed install method +- **THEN** the install method is labeled as a managed pip install +- **AND** the command guidance uses `pip install ` + +#### Scenario: Registering Mistral Vibe pip metadata + +- **WHEN** Quantex defines the supported Mistral Vibe agent entry +- **THEN** the entry identifies `mistral-vibe` as pip package metadata +- **AND** the pip install method is included as a managed install method on Windows, macOS, and Linux +- **AND** the entry continues to support other install methods such as script installers diff --git a/openspec/changes/add-pip-package-manager/specs/agent-update/spec.md b/openspec/changes/add-pip-package-manager/specs/agent-update/spec.md new file mode 100644 index 0000000..bfe5929 --- /dev/null +++ b/openspec/changes/add-pip-package-manager/specs/agent-update/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: pip-managed agent lifecycle MUST use pip commands + +pip-managed agent lifecycle operations SHALL install, update, batch update, uninstall, and diagnose agents through the pip installer when the recorded or selected install source is pip. + +#### Scenario: Updating pip-managed agents + +- **GIVEN** an agent has recorded install state with install type `pip` +- **WHEN** the user runs `quantex update ` or `quantex update --all` +- **THEN** Quantex selects the pip managed update path +- **AND** it runs pip with the recorded package name and `--upgrade` flag instead of guessing another package-manager source + +#### Scenario: Grouping pip-managed updates + +- **GIVEN** multiple installed agents have recorded pip install state +- **WHEN** the user runs `quantex update --all` +- **THEN** Quantex groups those updates by the pip installer +- **AND** it executes pip-managed batch update work without mixing the packages into npm, Bun, Homebrew, Cargo, or winget groups + +#### Scenario: Reporting pip installer availability + +- **GIVEN** the user runs `quantex capabilities` or `quantex doctor` +- **WHEN** Quantex reports managed installer availability +- **THEN** the output includes pip availability alongside Bun, npm, Homebrew, Cargo, and winget diff --git a/openspec/changes/add-pip-package-manager/tasks.md b/openspec/changes/add-pip-package-manager/tasks.md new file mode 100644 index 0000000..9181992 --- /dev/null +++ b/openspec/changes/add-pip-package-manager/tasks.md @@ -0,0 +1,20 @@ +## 1. Core Implementation + +- [x] 1.1 Add pip to managed install types, package metadata, install-method helpers, and installer capability classification. +- [x] 1.2 Add pip availability detection and pip installer implementation. +- [x] 1.3 Wire pip into managed installer lookup, install/update/uninstall execution, and batch update grouping. +- [x] 1.4 Render pip install commands and expose pip availability in `capabilities` and `doctor`. + +## 2. Agent Migration + +- [x] 2.1 Migrate Mistral Vibe agent definition from `binaryInstall('pip install mistral-vibe')` to managed `pipInstall('mistral-vibe')`. + +## 3. Tests + +- [x] 3.1 Add or update unit coverage for pip installer behavior, command rendering, update planning, and diagnostics. + +## 4. Validation + +- [x] 4.1 Run `bun run openspec:status -- --change add-pip-package-manager`. +- [x] 4.2 Run `bun run openspec:validate`. +- [x] 4.3 Run `bun run lint`, `bun run format:check`, `bun run typecheck`, and `bun run test`. diff --git a/src/agents/definitions/vibe.ts b/src/agents/definitions/vibe.ts index 1cfca08..317b38b 100644 --- a/src/agents/definitions/vibe.ts +++ b/src/agents/definitions/vibe.ts @@ -1,5 +1,5 @@ import type { AgentDefinition } from '../types' -import { binaryInstall, scriptInstall } from '../methods' +import { binaryInstall, pipInstall, scriptInstall } from '../methods' export const vibe: AgentDefinition = { name: 'vibe', @@ -7,20 +7,23 @@ export const vibe: AgentDefinition = { displayName: 'Mistral Vibe', homepage: 'https://docs.mistral.ai/mistral-vibe/terminal/install', binaryName: 'vibe', + packages: { + pip: 'mistral-vibe', + }, versionProbe: { command: ['vibe', '--version'], }, platforms: { - windows: [binaryInstall('uv tool install mistral-vibe'), binaryInstall('pip install mistral-vibe')], + windows: [binaryInstall('uv tool install mistral-vibe'), pipInstall('mistral-vibe')], macos: [ scriptInstall('curl -LsSf https://mistral.ai/vibe/install.sh | bash'), binaryInstall('uv tool install mistral-vibe'), - binaryInstall('pip install mistral-vibe'), + pipInstall('mistral-vibe'), ], linux: [ scriptInstall('curl -LsSf https://mistral.ai/vibe/install.sh | bash'), binaryInstall('uv tool install mistral-vibe'), - binaryInstall('pip install mistral-vibe'), + pipInstall('mistral-vibe'), ], }, } diff --git a/src/agents/methods.ts b/src/agents/methods.ts index cb06eb5..8f66818 100644 --- a/src/agents/methods.ts +++ b/src/agents/methods.ts @@ -30,6 +30,13 @@ export function cargoInstall(packageName?: string, packageInstallArgs?: string[] } } +export function pipInstall(packageName?: string): ManagedInstallMethod { + return { + packageName, + type: 'pip', + } +} + export function wingetInstall(packageName: string): ManagedInstallMethod { return { packageName, diff --git a/src/agents/types.ts b/src/agents/types.ts index 3bed62e..78c10dc 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -1,6 +1,6 @@ export type Platform = 'windows' | 'macos' | 'linux' -export type ManagedInstallType = 'bun' | 'npm' | 'brew' | 'cargo' | 'winget' +export type ManagedInstallType = 'bun' | 'npm' | 'brew' | 'cargo' | 'pip' | 'winget' export type InstallType = ManagedInstallType | 'script' | 'binary' export type PackageTargetKind = 'package' | 'cask' | 'id' @@ -31,6 +31,7 @@ export type InstallMethod = ManagedInstallMethod | ScriptInstallMethod | BinaryI export interface AgentPackageMetadata { cargo?: string npm?: string + pip?: string } export interface AgentSelfUpdate { diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index adbbac4..b771925 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -10,6 +10,7 @@ import { isBunAvailable, isCargoAvailable, isNpmAvailable, + isPipAvailable, isWingetAvailable, } from '../utils/detect' @@ -47,6 +48,10 @@ interface CapabilitiesData { available: boolean reason?: string } + pip: { + available: boolean + reason?: string + } winget: { available: boolean reason?: string @@ -60,12 +65,13 @@ interface CapabilitiesData { } export async function capabilitiesCommand(): Promise> { - const [bunAvailable, npmAvailable, brewAvailable, cargoAvailable, wingetAvailable, selfInspection] = + const [bunAvailable, npmAvailable, brewAvailable, cargoAvailable, pipAvailable, wingetAvailable, selfInspection] = await Promise.all([ isBunAvailable(), isNpmAvailable(), isBrewAvailable(), isCargoAvailable(), + isPipAvailable(), isWingetAvailable(), inspectSelf(), ]) @@ -99,13 +105,17 @@ export async function capabilitiesCommand(): Promise> { const npmAvailable = await isNpmAvailable() const brewAvailable = await isBrewAvailable() const cargoAvailable = await isCargoAvailable() + const pipAvailable = await isPipAvailable() const wingetAvailable = await isWingetAvailable() const selfInspection = await inspectSelf() @@ -91,14 +100,14 @@ export async function doctorCommand(): Promise> { const troubleshootingDocsRef = 'docs/runbooks/quantex-troubleshooting.md' const selfUpgradeDocsRef = 'docs/runbooks/release-and-self-upgrade-debugging.md' - if (!bunAvailable && !npmAvailable && !brewAvailable && !cargoAvailable && !wingetAvailable) { + if (!bunAvailable && !npmAvailable && !brewAvailable && !cargoAvailable && !pipAvailable && !wingetAvailable) { issues.push({ blocking: true, category: 'installers', code: 'NO_MANAGED_INSTALLER', docsRef: troubleshootingDocsRef, message: - 'No managed installer found. Install bun, npm, brew, cargo, or winget before relying on managed lifecycle operations.', + 'No managed installer found. Install bun, npm, brew, cargo, pip, or winget before relying on managed lifecycle operations.', severity: 'warning', subject: { kind: 'system' }, suggestedAction: 'restore-managed-installer', @@ -209,6 +218,7 @@ export async function doctorCommand(): Promise> { bun: bunAvailable, cargo: cargoAvailable, npm: npmAvailable, + pip: pipAvailable, winget: wingetAvailable, }, self: { @@ -239,6 +249,7 @@ function renderDoctorHuman(result: { data?: DoctorData }): void { console.log(` npm: ${result.data.installers.npm ? pc.green('available') : pc.red('not found')}`) console.log(` brew: ${result.data.installers.brew ? pc.green('available') : pc.red('not found')}`) console.log(` cargo: ${result.data.installers.cargo ? pc.green('available') : pc.red('not found')}`) + console.log(` pip: ${result.data.installers.pip ? pc.green('available') : pc.red('not found')}`) console.log(` winget:${result.data.installers.winget ? pc.green('available') : pc.red('not found')}`) console.log(`\n${pc.bold('Quantex CLI:')}`) diff --git a/src/package-manager/capabilities.ts b/src/package-manager/capabilities.ts index 5dc26f0..bef6cbf 100644 --- a/src/package-manager/capabilities.ts +++ b/src/package-manager/capabilities.ts @@ -44,6 +44,13 @@ const INSTALLER_CAPABILITIES: Record = { canUpdate: true, lifecycle: 'managed', }, + pip: { + canInstall: true, + canLookupLatestVersion: false, + canUninstall: true, + canUpdate: true, + lifecycle: 'managed', + }, script: { canInstall: true, canLookupLatestVersion: false, diff --git a/src/package-manager/installers.ts b/src/package-manager/installers.ts index 3ef8922..86bc455 100644 --- a/src/package-manager/installers.ts +++ b/src/package-manager/installers.ts @@ -1,10 +1,18 @@ import type { ManagedInstallType, PackageTargetKind } from '../agents/types' import type { NpmBunUpdateStrategy } from '../config' -import { isBrewAvailable, isBunAvailable, isCargoAvailable, isNpmAvailable, isWingetAvailable } from '../utils/detect' +import { + isBrewAvailable, + isBunAvailable, + isCargoAvailable, + isNpmAvailable, + isPipAvailable, + isWingetAvailable, +} from '../utils/detect' import * as brewPm from './brew' import * as bunPm from './bun' import * as cargoPm from './cargo' import * as npmPm from './npm' +import * as pipPm from './pip' import * as wingetPm from './winget' export interface ManagedPackageSpec { @@ -89,6 +97,14 @@ const managedInstallers: Record = { options?.npmBunUpdateStrategy, ), }, + pip: { + type: 'pip', + isAvailable: async () => isPipAvailable(), + install: async packageName => pipPm.install(packageName), + uninstall: async packageName => pipPm.uninstall(packageName), + update: async packageName => pipPm.update(packageName), + updateMany: async packages => pipPm.updateMany(packages.map(pkg => ({ packageName: pkg.packageName }))), + }, winget: { type: 'winget', isAvailable: async () => isWingetAvailable(), diff --git a/src/package-manager/pip.ts b/src/package-manager/pip.ts new file mode 100644 index 0000000..ebc8921 --- /dev/null +++ b/src/package-manager/pip.ts @@ -0,0 +1,55 @@ +import { readProcessOutput, spawnCommand, spawnWithQuantexStdio, waitForSpawnedCommand } from '../utils/child-process' + +let resolvedPipCommand: string[] | null = null + +async function resolvePipCommand(): Promise { + if (resolvedPipCommand) return resolvedPipCommand + + const candidates = [['pip'], ['pip3'], ['python', '-m', 'pip'], ['python3', '-m', 'pip']] + + for (const cmd of candidates) { + try { + const { exitCode } = await readProcessOutput(spawnCommand([...cmd, '--version'])) + if (exitCode === 0) { + resolvedPipCommand = cmd + return cmd + } + } catch { + /* continue to next candidate */ + } + } + + resolvedPipCommand = ['pip'] + return resolvedPipCommand +} + +async function runPipCommand(args: string[]): Promise { + try { + const pipCmd = await resolvePipCommand() + return (await waitForSpawnedCommand(spawnWithQuantexStdio([...pipCmd, ...args]))) === 0 + } catch { + return false + } +} + +export async function install(packageName: string): Promise { + return runPipCommand(['install', packageName]) +} + +export async function update(packageName: string): Promise { + return runPipCommand(['install', '--upgrade', packageName]) +} + +export async function updateMany(packages: Array<{ packageName: string }>): Promise { + if (packages.length === 0) return true + + for (const pkg of packages) { + if (!(await update(pkg.packageName))) return false + } + + return true +} + +export async function uninstall(packageName: string): Promise { + return runPipCommand(['uninstall', '-y', packageName]) +} diff --git a/src/planning/updates.ts b/src/planning/updates.ts index 9f988d5..8152ddf 100644 --- a/src/planning/updates.ts +++ b/src/planning/updates.ts @@ -29,6 +29,7 @@ export function createUpdatePlan( npm: [], brew: [], cargo: [], + pip: [], winget: [], } const manual: UpdatePlanEntry[] = [] @@ -85,7 +86,15 @@ export function createUpdatePlan( } return { - entries: [...grouped.bun, ...grouped.npm, ...grouped.brew, ...grouped.cargo, ...grouped.winget, ...manual], + entries: [ + ...grouped.bun, + ...grouped.npm, + ...grouped.brew, + ...grouped.cargo, + ...grouped.pip, + ...grouped.winget, + ...manual, + ], grouped, manual, skippedManualCheck, diff --git a/src/utils/detect.ts b/src/utils/detect.ts index 9419d48..d2a2d4d 100644 --- a/src/utils/detect.ts +++ b/src/utils/detect.ts @@ -42,6 +42,27 @@ export async function isWingetAvailable(): Promise { return isCommandAvailable('winget') } +export async function isPipAvailable(): Promise { + if (await isCommandAvailable('pip')) return true + if (await isCommandAvailable('pip3')) return true + return isPythonModulePipAvailable() +} + +async function isPythonModulePipAvailable(): Promise { + try { + const { exitCode } = await readProcessOutput(spawnCommand(['python', '-m', 'pip', '--version'])) + if (exitCode === 0) return true + } catch { + /* ignore */ + } + try { + const { exitCode } = await readProcessOutput(spawnCommand(['python3', '-m', 'pip', '--version'])) + return exitCode === 0 + } catch { + return false + } +} + export async function isBinaryInPath(binaryName: string): Promise { try { const cmd = process.platform === 'win32' ? 'where' : 'which' diff --git a/src/utils/install.ts b/src/utils/install.ts index 4c8677d..856f666 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -26,6 +26,7 @@ export function getManagedPackageName( ): string | undefined { if (method.packageName) return method.packageName if (method.type === 'cargo') return agent.packages?.cargo + if (method.type === 'pip') return agent.packages?.pip if (method.type === 'bun' || method.type === 'npm') return agent.packages?.npm return undefined } @@ -115,6 +116,11 @@ export function formatInstallMethodCommand(agent: Pick { expect(vibe.lookupAliases).toEqual(['mistral-vibe']) expect(vibe.displayName).toBe('Mistral Vibe') expect(vibe.binaryName).toBe('vibe') + expect(vibe.packages?.pip).toBe('mistral-vibe') expect(vibe.homepage).toBe('https://docs.mistral.ai/mistral-vibe/terminal/install') expect(vibe.versionProbe?.command).toEqual(['vibe', '--version']) expect(vibe.selfUpdate).toBeUndefined() @@ -720,7 +721,7 @@ describe('vibe', () => { for (const methods of Object.values(vibe.platforms)) { expect(methods!.find(m => m.type === 'binary' && m.command === 'uv tool install mistral-vibe')).toBeDefined() - expect(methods!.find(m => m.type === 'binary' && m.command === 'pip install mistral-vibe')).toBeDefined() + expect(methods!.find(m => m.type === 'pip' && m.packageName === 'mistral-vibe')).toBeDefined() } expect(vibe.platforms.windows!.find(m => m.type === 'script')).toBeUndefined()