Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/dsh-loopx-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ Requirements are Node.js 22.19+, `pnpm`, Python 3.11+ with `pip`, and network
access for the first DSH start when no compatible LoopX CLI is already
installed. LoopX itself is deliberately not a prerequisite. The initializer
honors an explicit `PYTHON_BIN`; otherwise it tries `python3`, then discovers
`python3.<minor>` executables on the supplied `PATH` in descending numeric order.
Installation still checks the Python version and pip; reopening the managed
runtime uses the same discovery and validates the LoopX CLI. No hard-coded
minor-version list is maintained. When selecting Python for installation, an
invalid explicit interpreter fails instead of falling back. Managed-runtime
readback keeps the existing CLI fallback behavior. If the plugin must install
or upgrade LoopX, it writes an isolated
`python3.<minor>` executables on the supplied `PATH` in descending numeric
order, then tries the bare name `python`. The last name is what a python.org
Windows install registers, so it keeps that host reachable when `python3` is
absent or is the `WindowsApps` App Execution Alias that never starts Python.
Unlike the versioned names it is not a `PATH` scan result; it is resolved when
the candidate runs. Installation still checks the Python version and pip;
reopening the managed runtime uses the same discovery and validates the LoopX
CLI. No hard-coded minor-version list is maintained. When selecting Python for
installation, an invalid explicit interpreter fails instead of falling back.
Managed-runtime readback keeps the existing CLI fallback behavior. If the
plugin must install or upgrade LoopX, it writes an isolated
copy under `$DSH_AGENTS_HOME/runtime/dsh-loopx-plugin` (default
`~/.agents/runtime/dsh-loopx-plugin`) and never mutates the system Python
environment. This works with externally managed Python distributions that
Expand Down
16 changes: 11 additions & 5 deletions packages/dsh-loopx-plugin/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,13 @@ export async function resolveLoopXCommand(
): Promise<LoopXCommand> {
const runner = options.runner ?? runFile
const configured = options.env?.LOOPX_BIN ?? process.env.LOOPX_BIN
const python = options.env?.PYTHON_BIN ?? process.env.PYTHON_BIN ?? 'python3'
const configuredPython = options.env?.PYTHON_BIN ?? process.env.PYTHON_BIN
// A python.org Windows install registers `python`, not `python3`, and the
// Windows `python3` name can be the WindowsApps alias that never starts
// Python, so the module fallback tries both spellings in that order.
const pythonNames = configuredPython === undefined
? ['python3', 'python']
: [configuredPython]
const managedLauncher = options.managedLauncher
const managedCandidates: Array<Omit<LoopXCommand, 'version'>> = configured
|| managedLauncher === undefined
Expand All @@ -352,11 +358,11 @@ export async function resolveLoopXCommand(
: [
...managedCandidates,
{ file: 'loopx', prefix: [], skillCommand: 'loopx' },
{
file: python,
...pythonNames.map(pythonName => ({
file: pythonName,
prefix: ['-m', 'loopx.cli'],
skillCommand: `${shellWord(python)} -m loopx.cli`,
},
skillCommand: `${shellWord(pythonName)} -m loopx.cli`,
})),
]

for (const candidate of candidates) {
Expand Down
11 changes: 9 additions & 2 deletions packages/dsh-loopx-plugin/src/managed-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,16 @@ export function pluginPythonCandidates(
}
}
// Preserve python3-first behavior; callers still probe the actual version/pip.
return ['python3', ...[...discovered]
// `python` is a bare last-resort name, not a PATH scan result: a python.org
// Windows install registers `python.exe`, and the host's `python3` name can be
// the WindowsApps alias that never starts Python. The same fallback pair is
// used by the Claude Code adapter installer
// (loopx/claude_goal_mode/scripts/install.py), and the callers above still
// reject an interpreter that fails the version/pip probe.
const discoveredNames = [...discovered]
.sort(([nameA, minorA], [nameB, minorB]) => minorB - minorA || nameA.localeCompare(nameB))
.map(([name]) => name)]
.map(([name]) => name)
return ['python3', ...discoveredNames, 'python']
}

export function pluginAgentsHome(options: LoopXRuntimeOptions): string {
Expand Down
17 changes: 17 additions & 0 deletions packages/dsh-loopx-plugin/tests/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ describe('runJsonCommand', () => {
expect(command.prefix).toEqual(['-m', 'loopx.cli'])
})

it('tries the python name a python.org Windows PATH registers for the module fallback', async () => {
const attempted: string[] = []
const resolved = await resolveLoopXCommand({
env: { LOOPX_BIN: '' },
runner: async (file, args) => {
attempted.push(`${file} ${args.join(' ')}`)
if (file === 'python') return { exitCode: 0, stdout: 'loopx 1.2.4\n', stderr: '' }
throw new LoopXCliError('missing', 'missing', false)
},
})

expect(attempted).toContain('python3 -m loopx.cli --version')
expect(resolved.file).toBe('python')
expect(resolved.prefix).toEqual(['-m', 'loopx.cli'])
expect(resolved.skillCommand).toBe('python -m loopx.cli')
})

it('keeps a configured LoopX executable as one quoted shell word', async () => {
const configured = '/opt/Loop X/loopx'
const resolved = await resolveLoopXCommand({
Expand Down
63 changes: 63 additions & 0 deletions packages/dsh-loopx-plugin/tests/init-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,69 @@ describe('/loopx-init implementation', () => {
))).toBe(true)
})

it('finds the interpreter a python.org Windows PATH registers', async () => {
// A python.org Windows install registers python.exe and no python3 name,
// and the host's python3 name can be the WindowsApps alias, which starts
// and exits 9009 without running Python.
const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-windows-pythons-'))
onTestFinished(() => rm(pythonPath, { recursive: true, force: true }))
await writeFile(join(pythonPath, 'python.exe'), '# fixture executable\n')
const calls: Array<{ readonly file: string; readonly args: readonly string[] }> = []
let installed = false
const runner: FileRunner = async (file, args) => {
calls.push({ file, args: [...args] })
if (file === 'python3') {
return { exitCode: 9009, stdout: '', stderr: '' }
}
if (args[0] === '-c') {
return { exitCode: 0, stdout: '', stderr: '' }
}
if (args.join(' ') === '-m pip --version') {
return { exitCode: 0, stdout: 'pip fixture', stderr: '' }
}
if (args.slice(0, 3).join(' ') === '-m pip install') {
installed = true
return { exitCode: 0, stdout: 'installed', stderr: '' }
}
if (args.at(-1) === '--version') {
if (!installed) throw new LoopXCliError('missing', 'missing', false)
return { exitCode: 0, stdout: 'loopx 1.2.4\n', stderr: '' }
}
if (args.includes('workflow-skills')) {
const operation = args.includes('--install') ? 'install' : 'inspect'
return {
exitCode: 0,
stdout: workflowPayload(operation, false),
stderr: '',
}
}
throw new Error(`unexpected argv: ${file} ${args.join(' ')}`)
}

const result = await initializeLoopX({
runner,
env: { PATH: pythonPath },
skillsDir: '/fixture/skills',
runtimeDir: '/fixture/runtime',
})

expect(result.cliInstalled).toBe(true)
expect(calls.some(call => (
call.file === 'python3'
&& call.args[0] === '-c'
))).toBe(true)
const pipCall = calls.find(call => call.args.slice(0, 3).join(' ') === '-m pip install')
expect(pipCall?.file).toBe('python')
const workflowCalls = calls.filter(call => call.args.includes('workflow-skills'))
expect(workflowCalls.every(call => call.file === 'python')).toBe(true)
const cliBins = workflowCalls.map(call => call.args[call.args.indexOf('--cli-bin') + 1])
expect(cliBins.every(value => (
typeof value === 'string'
&& value.startsWith('python ')
&& value.includes('loopx_cli.py')
))).toBe(true)
})

it('skips an implicit version-compatible Python that cannot run pip', async () => {
const pythonPath = await mkdtemp(join(tmpdir(), 'loopx-installed-pythons-'))
onTestFinished(() => rm(pythonPath, { recursive: true, force: true }))
Expand Down
24 changes: 21 additions & 3 deletions packages/dsh-loopx-plugin/tests/python-discovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,38 @@ describe('installed Python discovery', () => {
}
await mkdir(join(directory, 'python3.998'))
expect(pluginPythonCandidates({ env: { PATH: directory } })).toEqual([
'python3', 'python3.97', 'python3.12', 'python3.9',
'python3', 'python3.97', 'python3.12', 'python3.9', 'python',
])
expect(pluginPythonCandidates({ env: { PATH: `${directory}${delimiter}${directory}` } }))
.toEqual(['python3', 'python3.97', 'python3.12', 'python3.9'])
.toEqual(['python3', 'python3.97', 'python3.12', 'python3.9', 'python'])
} finally {
await rm(directory, { recursive: true, force: true })
}
})

it('discovers a versioned Windows interpreter and ignores a bare-name alias stub', async () => {
const alias = await mkdtemp(join(tmpdir(), 'loopx-python-alias-'))
const host = await mkdtemp(join(tmpdir(), 'loopx-python-host-'))
try {
// The WindowsApps alias directory carries a stub python3.exe that starts
// and exits 9009 without running Python and names no minor version; a
// Windows host can also register python3.12.exe next to python.exe.
await writeFile(join(alias, 'python3.exe'), '')
await writeFile(join(host, 'python3.12.exe'), '# fixture executable\n')
await writeFile(join(host, 'python.exe'), '# fixture executable\n')
expect(pluginPythonCandidates({ env: { PATH: `${alias}${delimiter}${host}` } }))
.toEqual(['python3', 'python3.12.exe', 'python'])
} finally {
await rm(alias, { recursive: true, force: true })
await rm(host, { recursive: true, force: true })
}
})

it('keeps an explicit interpreter authoritative and tolerates missing PATH directories', () => {
expect(pluginPythonCandidates({ pythonBin: '/configured/python', env: { PATH: '/missing' } }))
.toEqual(['/configured/python'])
expect(pluginPythonCandidates({ env: { PATH: '/missing', PYTHON_BIN: '/custom/python' } }))
.toEqual(['/custom/python'])
expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3'])
expect(pluginPythonCandidates({ env: { PATH: '/missing' } })).toEqual(['python3', 'python'])
})
})
Loading