@carter-mcalister/pi-mise-toolchain 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/package.json +76 -0
- package/src/blockers/index.ts +23 -0
- package/src/blockers/package-manager.ts +62 -0
- package/src/blockers/python.ts +61 -0
- package/src/commands/settings-command.ts +72 -0
- package/src/config.ts +325 -0
- package/src/hooks/bash-integration.ts +35 -0
- package/src/hooks/rewrite-notifications.ts +42 -0
- package/src/hooks/session-start.ts +10 -0
- package/src/index.ts +47 -0
- package/src/project-config.ts +98 -0
- package/src/rewriters/git-rebase.ts +75 -0
- package/src/rewriters/index.ts +49 -0
- package/src/rewriters/package-manager.ts +152 -0
- package/src/rewriters/python.ts +130 -0
- package/src/rewriters/types.ts +12 -0
- package/src/toolchain.test.ts +322 -0
- package/src/utils/bash-composition.ts +28 -0
- package/src/utils/bash-source-mode.ts +5 -0
- package/src/utils/migration.ts +100 -0
- package/src/utils/shell-utils.ts +245 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package manager rewriter.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites commands that use a non-selected package manager to use the
|
|
5
|
+
* selected one. Uses AST parsing for surgical string replacement at exact
|
|
6
|
+
* character positions to avoid corrupting arguments or strings.
|
|
7
|
+
*
|
|
8
|
+
* If AST parse fails, returns the command unchanged (no regex fallback
|
|
9
|
+
* for rewrites -- a false positive rewrite is worse than a missed one).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Program } from '@aliou/sh'
|
|
13
|
+
import { parse } from '@aliou/sh'
|
|
14
|
+
import type { ResolvedToolchainConfig } from '../config'
|
|
15
|
+
import { walkCommands, wordToString } from '../utils/shell-utils'
|
|
16
|
+
import type { Rewriter } from './types'
|
|
17
|
+
|
|
18
|
+
type PackageManager = 'bun' | 'pnpm' | 'npm'
|
|
19
|
+
|
|
20
|
+
const ALL_MANAGERS = new Set<string>(['bun', 'pnpm', 'npm', 'npx', 'yarn'])
|
|
21
|
+
|
|
22
|
+
/** Maps npx-like commands to the selected manager's equivalent. */
|
|
23
|
+
const NPX_EQUIVALENT: Record<PackageManager, string> = {
|
|
24
|
+
pnpm: 'pnpm dlx',
|
|
25
|
+
bun: 'bunx',
|
|
26
|
+
npm: 'npx',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Replacement {
|
|
30
|
+
/** Start character offset in the original command string. */
|
|
31
|
+
start: number
|
|
32
|
+
/** End character offset (exclusive) in the original command string. */
|
|
33
|
+
end: number
|
|
34
|
+
/** The replacement text. */
|
|
35
|
+
text: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPackageManagerRewriter(
|
|
39
|
+
config: ResolvedToolchainConfig,
|
|
40
|
+
): Rewriter {
|
|
41
|
+
const selected = config.packageManager.selected
|
|
42
|
+
|
|
43
|
+
return (ctx) => {
|
|
44
|
+
let ast: Program
|
|
45
|
+
try {
|
|
46
|
+
;({ ast } = parse(ctx.command))
|
|
47
|
+
} catch {
|
|
48
|
+
return { ctx, notices: [] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const replacements: Replacement[] = []
|
|
52
|
+
|
|
53
|
+
walkCommands(ast, (cmd) => {
|
|
54
|
+
const firstWord = cmd.words?.[0]
|
|
55
|
+
if (!firstWord) return
|
|
56
|
+
|
|
57
|
+
const name = wordToString(firstWord)
|
|
58
|
+
if (!ALL_MANAGERS.has(name) || name === selected) return
|
|
59
|
+
|
|
60
|
+
// Get the literal part for position info. Only rewrite if the
|
|
61
|
+
// first part is a simple Literal (no expansions in command name).
|
|
62
|
+
const firstPart = firstWord.parts[0]
|
|
63
|
+
if (!firstPart || firstPart.type !== 'Literal') return
|
|
64
|
+
|
|
65
|
+
const literalValue = firstPart.value
|
|
66
|
+
// The word's position in the source is derived from the literal value.
|
|
67
|
+
// @aliou/sh doesn't expose source positions, so we find the command
|
|
68
|
+
// name in the source string. We search from after the last replacement
|
|
69
|
+
// to handle multiple commands.
|
|
70
|
+
const searchFrom =
|
|
71
|
+
replacements.length > 0
|
|
72
|
+
? (replacements[replacements.length - 1] as Replacement).end
|
|
73
|
+
: 0
|
|
74
|
+
|
|
75
|
+
const idx = findCommandPosition(ctx.command, literalValue, searchFrom)
|
|
76
|
+
if (idx === -1) return
|
|
77
|
+
|
|
78
|
+
if (name === 'npx') {
|
|
79
|
+
replacements.push({
|
|
80
|
+
start: idx,
|
|
81
|
+
end: idx + literalValue.length,
|
|
82
|
+
text: NPX_EQUIVALENT[selected],
|
|
83
|
+
})
|
|
84
|
+
} else if (name === 'yarn') {
|
|
85
|
+
replacements.push({
|
|
86
|
+
start: idx,
|
|
87
|
+
end: idx + literalValue.length,
|
|
88
|
+
text: selected,
|
|
89
|
+
})
|
|
90
|
+
} else if (name !== selected) {
|
|
91
|
+
// npm, pnpm, or bun -> selected
|
|
92
|
+
replacements.push({
|
|
93
|
+
start: idx,
|
|
94
|
+
end: idx + literalValue.length,
|
|
95
|
+
text: selected,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return undefined
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (replacements.length === 0) return { ctx, notices: [] }
|
|
103
|
+
|
|
104
|
+
// Apply replacements from right to left so offsets remain valid.
|
|
105
|
+
let result = ctx.command
|
|
106
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
107
|
+
const r = replacements[i] as Replacement
|
|
108
|
+
result = result.slice(0, r.start) + r.text + result.slice(r.end)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ctx: { ...ctx, command: result },
|
|
113
|
+
notices:
|
|
114
|
+
result === ctx.command
|
|
115
|
+
? []
|
|
116
|
+
: [{ message: `Rewrote command: ${ctx.command} -> ${result}` }],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the position of a command name in the source string, starting
|
|
123
|
+
* from `searchFrom`. Matches on word boundary to avoid matching inside
|
|
124
|
+
* paths or URLs.
|
|
125
|
+
*/
|
|
126
|
+
function findCommandPosition(
|
|
127
|
+
source: string,
|
|
128
|
+
name: string,
|
|
129
|
+
searchFrom: number,
|
|
130
|
+
): number {
|
|
131
|
+
let pos = searchFrom
|
|
132
|
+
while (pos < source.length) {
|
|
133
|
+
const idx = source.indexOf(name, pos)
|
|
134
|
+
if (idx === -1) return -1
|
|
135
|
+
|
|
136
|
+
// Check word boundaries: char before must be start-of-string or
|
|
137
|
+
// a shell delimiter, char after must be end-of-string or delimiter.
|
|
138
|
+
const before = idx > 0 ? source[idx - 1] : undefined
|
|
139
|
+
const after =
|
|
140
|
+
idx + name.length < source.length ? source[idx + name.length] : undefined
|
|
141
|
+
|
|
142
|
+
const validBefore =
|
|
143
|
+
before === undefined || /[\s;|&(]/.test(before) || before === '\n'
|
|
144
|
+
const validAfter =
|
|
145
|
+
after === undefined || /[\s;|&)]/.test(after) || after === '\n'
|
|
146
|
+
|
|
147
|
+
if (validBefore && validAfter) return idx
|
|
148
|
+
|
|
149
|
+
pos = idx + 1
|
|
150
|
+
}
|
|
151
|
+
return -1
|
|
152
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python/pip rewriter.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites python/pip commands to uv equivalents:
|
|
5
|
+
* python script.py -> uv run python script.py
|
|
6
|
+
* python3 script.py -> uv run python3 script.py
|
|
7
|
+
* pip install X -> uv pip install X
|
|
8
|
+
* pip3 install X -> uv pip install X
|
|
9
|
+
*
|
|
10
|
+
* Does NOT rewrite poetry, pyenv, virtualenv -- those are blocked by
|
|
11
|
+
* the python-confirm blocker, not rewritten.
|
|
12
|
+
*
|
|
13
|
+
* If AST parse fails, returns the command unchanged (no regex fallback
|
|
14
|
+
* for rewrites).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Program } from '@aliou/sh'
|
|
18
|
+
import { parse } from '@aliou/sh'
|
|
19
|
+
import { walkCommands, wordToString } from '../utils/shell-utils'
|
|
20
|
+
import type { Rewriter } from './types'
|
|
21
|
+
|
|
22
|
+
const PYTHON_COMMANDS = new Set(['python', 'python3'])
|
|
23
|
+
const PIP_COMMANDS = new Set(['pip', 'pip3'])
|
|
24
|
+
|
|
25
|
+
interface Replacement {
|
|
26
|
+
start: number
|
|
27
|
+
end: number
|
|
28
|
+
text: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createPythonRewriter(): Rewriter {
|
|
32
|
+
return (ctx) => {
|
|
33
|
+
let ast: Program
|
|
34
|
+
try {
|
|
35
|
+
;({ ast } = parse(ctx.command))
|
|
36
|
+
} catch {
|
|
37
|
+
return { ctx, notices: [] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const replacements: Replacement[] = []
|
|
41
|
+
|
|
42
|
+
walkCommands(ast, (cmd) => {
|
|
43
|
+
const firstWord = cmd.words?.[0]
|
|
44
|
+
if (!firstWord) return
|
|
45
|
+
|
|
46
|
+
const name = wordToString(firstWord)
|
|
47
|
+
|
|
48
|
+
const isPython = PYTHON_COMMANDS.has(name)
|
|
49
|
+
const isPip = PIP_COMMANDS.has(name)
|
|
50
|
+
if (!isPython && !isPip) return
|
|
51
|
+
|
|
52
|
+
const firstPart = firstWord.parts[0]
|
|
53
|
+
if (!firstPart || firstPart.type !== 'Literal') return
|
|
54
|
+
|
|
55
|
+
const literalValue = firstPart.value
|
|
56
|
+
const searchFrom =
|
|
57
|
+
replacements.length > 0
|
|
58
|
+
? (replacements[replacements.length - 1] as Replacement).end
|
|
59
|
+
: 0
|
|
60
|
+
|
|
61
|
+
const idx = findCommandPosition(ctx.command, literalValue, searchFrom)
|
|
62
|
+
if (idx === -1) return
|
|
63
|
+
|
|
64
|
+
if (isPython) {
|
|
65
|
+
// python X -> uv run python X (prepend "uv run ")
|
|
66
|
+
replacements.push({
|
|
67
|
+
start: idx,
|
|
68
|
+
end: idx,
|
|
69
|
+
text: 'uv run ',
|
|
70
|
+
})
|
|
71
|
+
} else {
|
|
72
|
+
// pip X -> uv pip X (replace pip/pip3 with "uv pip")
|
|
73
|
+
replacements.push({
|
|
74
|
+
start: idx,
|
|
75
|
+
end: idx + literalValue.length,
|
|
76
|
+
text: 'uv pip',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return undefined
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (replacements.length === 0) return { ctx, notices: [] }
|
|
84
|
+
|
|
85
|
+
// Apply replacements from right to left so offsets remain valid.
|
|
86
|
+
let result = ctx.command
|
|
87
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
88
|
+
const r = replacements[i] as Replacement
|
|
89
|
+
result = result.slice(0, r.start) + r.text + result.slice(r.end)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ctx: { ...ctx, command: result },
|
|
94
|
+
notices:
|
|
95
|
+
result === ctx.command
|
|
96
|
+
? []
|
|
97
|
+
: [{ message: `Rewrote command: ${ctx.command} -> ${result}` }],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the position of a command name in the source string, starting
|
|
104
|
+
* from `searchFrom`. Matches on word boundary.
|
|
105
|
+
*/
|
|
106
|
+
function findCommandPosition(
|
|
107
|
+
source: string,
|
|
108
|
+
name: string,
|
|
109
|
+
searchFrom: number,
|
|
110
|
+
): number {
|
|
111
|
+
let pos = searchFrom
|
|
112
|
+
while (pos < source.length) {
|
|
113
|
+
const idx = source.indexOf(name, pos)
|
|
114
|
+
if (idx === -1) return -1
|
|
115
|
+
|
|
116
|
+
const before = idx > 0 ? source[idx - 1] : undefined
|
|
117
|
+
const after =
|
|
118
|
+
idx + name.length < source.length ? source[idx + name.length] : undefined
|
|
119
|
+
|
|
120
|
+
const validBefore =
|
|
121
|
+
before === undefined || /[\s;|&(]/.test(before) || before === '\n'
|
|
122
|
+
const validAfter =
|
|
123
|
+
after === undefined || /[\s;|&)]/.test(after) || after === '\n'
|
|
124
|
+
|
|
125
|
+
if (validBefore && validAfter) return idx
|
|
126
|
+
|
|
127
|
+
pos = idx + 1
|
|
128
|
+
}
|
|
129
|
+
return -1
|
|
130
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BashSpawnContext } from '@mariozechner/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
export interface RewriteNotice {
|
|
4
|
+
message: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RewriteResult {
|
|
8
|
+
ctx: BashSpawnContext
|
|
9
|
+
notices: RewriteNotice[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Rewriter = (ctx: BashSpawnContext) => RewriteResult
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'
|
|
6
|
+
import {
|
|
7
|
+
configLoader,
|
|
8
|
+
DEFAULT_CONFIG,
|
|
9
|
+
DEFAULT_EXTENSION_CONFIG,
|
|
10
|
+
findLegacyLocalConfigPath,
|
|
11
|
+
getIgnoredLegacyProjectSettingsWarning,
|
|
12
|
+
type ResolvedToolchainConfig,
|
|
13
|
+
resolveExtensionConfig,
|
|
14
|
+
resolveRuntimeConfig,
|
|
15
|
+
type ToolchainConfig,
|
|
16
|
+
} from './config'
|
|
17
|
+
import { registerBashIntegration } from './hooks/bash-integration'
|
|
18
|
+
import {
|
|
19
|
+
hasRewriteFeatures,
|
|
20
|
+
registerRewriteNotifications,
|
|
21
|
+
} from './hooks/rewrite-notifications'
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_PROJECT_TOOLCHAIN_CONFIG,
|
|
24
|
+
findProjectToolchainConfig,
|
|
25
|
+
} from './project-config'
|
|
26
|
+
import {
|
|
27
|
+
BASH_SPAWN_HOOK_REQUEST_EVENT,
|
|
28
|
+
TOOLCHAIN_SPAWN_HOOK_CONTRIBUTOR_ID,
|
|
29
|
+
} from './utils/bash-composition'
|
|
30
|
+
import {
|
|
31
|
+
CURRENT_VERSION,
|
|
32
|
+
isMissingBashSourceMode,
|
|
33
|
+
migrateV0,
|
|
34
|
+
} from './utils/migration'
|
|
35
|
+
|
|
36
|
+
function createPiStub() {
|
|
37
|
+
const toolCallHandlers: Array<Parameters<ExtensionAPI['on']>[1]> = []
|
|
38
|
+
const eventHandlers = new Map<string, (data: unknown) => void>()
|
|
39
|
+
const registeredTools: unknown[] = []
|
|
40
|
+
|
|
41
|
+
const pi = {
|
|
42
|
+
on(eventName: string, handler: Parameters<ExtensionAPI['on']>[1]) {
|
|
43
|
+
if (eventName === 'tool_call') {
|
|
44
|
+
toolCallHandlers.push(handler)
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
registerTool(tool: unknown) {
|
|
48
|
+
registeredTools.push(tool)
|
|
49
|
+
},
|
|
50
|
+
events: {
|
|
51
|
+
on(eventName: string, handler: (data: unknown) => void) {
|
|
52
|
+
eventHandlers.set(eventName, handler)
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
} as unknown as ExtensionAPI
|
|
56
|
+
|
|
57
|
+
return { pi, toolCallHandlers, eventHandlers, registeredTools }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withRuntimeConfig(
|
|
61
|
+
extensionConfig: ToolchainConfig,
|
|
62
|
+
projectConfig = DEFAULT_PROJECT_TOOLCHAIN_CONFIG,
|
|
63
|
+
): ResolvedToolchainConfig {
|
|
64
|
+
return resolveRuntimeConfig(
|
|
65
|
+
resolveExtensionConfig(extensionConfig),
|
|
66
|
+
projectConfig,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('toolchain config', () => {
|
|
71
|
+
it('defaults bash.sourceMode to override-bash', () => {
|
|
72
|
+
const resolved = resolveExtensionConfig({})
|
|
73
|
+
|
|
74
|
+
expect(resolved.bash.sourceMode).toBe('override-bash')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('rejects invalid bash.sourceMode', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
resolveExtensionConfig({
|
|
80
|
+
bash: {
|
|
81
|
+
sourceMode: 'wrong-mode' as 'override-bash',
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
).toThrow(/bash\.sourceMode must be "override-bash" or "composed-bash"/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('migrateV0 handles legacy feature migration and leaves sourceMode to the dedicated migration', () => {
|
|
88
|
+
const migrated = migrateV0({
|
|
89
|
+
enabled: true,
|
|
90
|
+
features: {
|
|
91
|
+
enforcePackageManager: true as unknown as never,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(migrated.bash?.sourceMode).toBeUndefined()
|
|
96
|
+
expect(isMissingBashSourceMode(migrated)).toBe(true)
|
|
97
|
+
expect(migrated.version).toBe(CURRENT_VERSION)
|
|
98
|
+
expect(migrated.features?.enforcePackageManager).toBe('rewrite')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does not run missing-source-mode migration when sourceMode already exists', () => {
|
|
102
|
+
const config = {
|
|
103
|
+
version: '0.5.1-old',
|
|
104
|
+
bash: { sourceMode: 'composed-bash' },
|
|
105
|
+
} satisfies ToolchainConfig
|
|
106
|
+
|
|
107
|
+
expect(isMissingBashSourceMode(config)).toBe(false)
|
|
108
|
+
expect(resolveExtensionConfig(config).bash.sourceMode).toBe('composed-bash')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('uses only global and memory scopes for JSON-backed settings', () => {
|
|
112
|
+
expect(configLoader.getEnabledScopes()).toEqual(['global', 'memory'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('warns when legacy JSON project toolchain settings are still present', () => {
|
|
116
|
+
expect(
|
|
117
|
+
getIgnoredLegacyProjectSettingsWarning({
|
|
118
|
+
features: { rewritePython: 'rewrite' },
|
|
119
|
+
packageManager: { selected: 'pnpm' },
|
|
120
|
+
}),
|
|
121
|
+
).toMatch(/nearest mise\.toml/)
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
getIgnoredLegacyProjectSettingsWarning({
|
|
125
|
+
features: { gitRebaseEditor: 'rewrite' },
|
|
126
|
+
}),
|
|
127
|
+
).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('finds an ignored legacy local toolchain config in parent directories', async () => {
|
|
131
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-local-config-'))
|
|
132
|
+
const nestedDir = join(root, 'packages', 'extension')
|
|
133
|
+
const legacyConfigPath = join(root, '.pi', 'extensions', 'toolchain.json')
|
|
134
|
+
|
|
135
|
+
await mkdir(join(root, '.pi', 'extensions'), { recursive: true })
|
|
136
|
+
await writeFile(legacyConfigPath, '{}\n')
|
|
137
|
+
await mkdir(nestedDir, { recursive: true })
|
|
138
|
+
|
|
139
|
+
expect(findLegacyLocalConfigPath(nestedDir)).toBe(legacyConfigPath)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('DEFAULT_CONFIG keeps backward-compatible override-bash behavior', () => {
|
|
143
|
+
expect(DEFAULT_CONFIG.bash.sourceMode).toBe('override-bash')
|
|
144
|
+
expect(DEFAULT_EXTENSION_CONFIG.bash.sourceMode).toBe('override-bash')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('project toolchain config from mise.toml', () => {
|
|
149
|
+
it('derives python rewrite when mise.toml declares uv', async () => {
|
|
150
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-mise-'))
|
|
151
|
+
await writeFile(join(root, 'mise.toml'), "[tools]\nuv = 'latest'\n")
|
|
152
|
+
|
|
153
|
+
const project = await findProjectToolchainConfig(root)
|
|
154
|
+
|
|
155
|
+
expect(project.features.rewritePython).toBe('rewrite')
|
|
156
|
+
expect(project.features.enforcePackageManager).toBe('disabled')
|
|
157
|
+
expect(project.packageManager.selected).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('enables package-manager rewriting only when exactly one supported manager is declared', async () => {
|
|
161
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-mise-'))
|
|
162
|
+
await writeFile(join(root, 'mise.toml'), "[tools]\npnpm = '10'\n")
|
|
163
|
+
|
|
164
|
+
const project = await findProjectToolchainConfig(root)
|
|
165
|
+
|
|
166
|
+
expect(project.features.enforcePackageManager).toBe('rewrite')
|
|
167
|
+
expect(project.packageManager.selected).toBe('pnpm')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('disables package-manager rewriting when multiple managers are declared', async () => {
|
|
171
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-mise-'))
|
|
172
|
+
await writeFile(
|
|
173
|
+
join(root, 'mise.toml'),
|
|
174
|
+
"[tools]\npnpm = '10'\nbun = '1'\n",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
const project = await findProjectToolchainConfig(root)
|
|
178
|
+
|
|
179
|
+
expect(project.features.enforcePackageManager).toBe('disabled')
|
|
180
|
+
expect(project.packageManager.selected).toBeNull()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('uses the nearest ancestor mise.toml', async () => {
|
|
184
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-mise-'))
|
|
185
|
+
const nestedDir = join(root, 'packages', 'extension')
|
|
186
|
+
|
|
187
|
+
await writeFile(join(root, 'mise.toml'), "[tools]\npnpm = '10'\n")
|
|
188
|
+
await mkdir(nestedDir, { recursive: true })
|
|
189
|
+
await writeFile(join(nestedDir, 'mise.toml'), "[tools]\nbun = '1'\n")
|
|
190
|
+
|
|
191
|
+
const project = await findProjectToolchainConfig(join(nestedDir, 'src'))
|
|
192
|
+
|
|
193
|
+
expect(project.packageManager.selected).toBe('bun')
|
|
194
|
+
expect(project.features.enforcePackageManager).toBe('rewrite')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('fails open when mise.toml is malformed', async () => {
|
|
198
|
+
const root = await mkdtemp(join(tmpdir(), 'toolchain-mise-'))
|
|
199
|
+
await writeFile(join(root, 'mise.toml'), '[tools\nuv = ')
|
|
200
|
+
|
|
201
|
+
const project = await findProjectToolchainConfig(root)
|
|
202
|
+
|
|
203
|
+
expect(project.features.rewritePython).toBe('disabled')
|
|
204
|
+
expect(project.features.enforcePackageManager).toBe('disabled')
|
|
205
|
+
expect(project.packageManager.selected).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('toolchain bash integration', () => {
|
|
210
|
+
it('hasRewriteFeatures is false when no rewrite feature is enabled', () => {
|
|
211
|
+
const config = withRuntimeConfig({
|
|
212
|
+
features: {
|
|
213
|
+
gitRebaseEditor: 'disabled',
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(hasRewriteFeatures(config)).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('registers local bash in override-bash mode', () => {
|
|
221
|
+
const { pi, eventHandlers, registeredTools } = createPiStub()
|
|
222
|
+
const config = withRuntimeConfig(
|
|
223
|
+
{
|
|
224
|
+
bash: { sourceMode: 'override-bash' },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
sourcePath: '/tmp/mise.toml',
|
|
228
|
+
features: {
|
|
229
|
+
enforcePackageManager: 'rewrite',
|
|
230
|
+
rewritePython: 'disabled',
|
|
231
|
+
},
|
|
232
|
+
packageManager: {
|
|
233
|
+
selected: 'pnpm',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
registerBashIntegration(pi, config)
|
|
239
|
+
|
|
240
|
+
expect(registeredTools).toHaveLength(1)
|
|
241
|
+
expect(eventHandlers.has(BASH_SPAWN_HOOK_REQUEST_EVENT)).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('contributes to composer in composed-bash mode', () => {
|
|
245
|
+
const { pi, eventHandlers, registeredTools } = createPiStub()
|
|
246
|
+
const config = withRuntimeConfig(
|
|
247
|
+
{
|
|
248
|
+
bash: { sourceMode: 'composed-bash' },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
sourcePath: '/tmp/mise.toml',
|
|
252
|
+
features: {
|
|
253
|
+
enforcePackageManager: 'rewrite',
|
|
254
|
+
rewritePython: 'disabled',
|
|
255
|
+
},
|
|
256
|
+
packageManager: {
|
|
257
|
+
selected: 'pnpm',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
registerBashIntegration(pi, config)
|
|
263
|
+
|
|
264
|
+
expect(registeredTools).toHaveLength(0)
|
|
265
|
+
const handler = eventHandlers.get(BASH_SPAWN_HOOK_REQUEST_EVENT)
|
|
266
|
+
expect(handler).toBeTypeOf('function')
|
|
267
|
+
|
|
268
|
+
const contributions: Array<{ id: string; spawnHook: unknown }> = []
|
|
269
|
+
handler?.({
|
|
270
|
+
register(contributor: { id: string; spawnHook: unknown }) {
|
|
271
|
+
contributions.push(contributor)
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(contributions).toHaveLength(1)
|
|
276
|
+
expect(contributions[0]?.id).toBe(TOOLCHAIN_SPAWN_HOOK_CONTRIBUTOR_ID)
|
|
277
|
+
expect(contributions[0]?.spawnHook).toBeTypeOf('function')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('rewrite notifications include source-mode prefix', async () => {
|
|
281
|
+
const { pi, toolCallHandlers } = createPiStub()
|
|
282
|
+
const config = withRuntimeConfig(
|
|
283
|
+
{
|
|
284
|
+
features: { gitRebaseEditor: 'rewrite' },
|
|
285
|
+
bash: { sourceMode: 'composed-bash' },
|
|
286
|
+
ui: { showRewriteNotifications: true },
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
sourcePath: '/tmp/mise.toml',
|
|
290
|
+
features: {
|
|
291
|
+
enforcePackageManager: 'disabled',
|
|
292
|
+
rewritePython: 'disabled',
|
|
293
|
+
},
|
|
294
|
+
packageManager: {
|
|
295
|
+
selected: null,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
registerRewriteNotifications(pi, config)
|
|
301
|
+
|
|
302
|
+
expect(toolCallHandlers).toHaveLength(1)
|
|
303
|
+
|
|
304
|
+
const messages: string[] = []
|
|
305
|
+
await toolCallHandlers[0](
|
|
306
|
+
{
|
|
307
|
+
toolName: 'bash',
|
|
308
|
+
input: { command: 'git rebase -i HEAD~1' },
|
|
309
|
+
} as never,
|
|
310
|
+
{
|
|
311
|
+
ui: {
|
|
312
|
+
notify(message: string) {
|
|
313
|
+
messages.push(message)
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
} as never,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
expect(messages).toHaveLength(1)
|
|
320
|
+
expect(messages[0] ?? '').toMatch(/^\[composed-bash\] /)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BashSpawnContext } from '@mariozechner/pi-coding-agent'
|
|
2
|
+
import type { BashSourceMode } from '../config'
|
|
3
|
+
|
|
4
|
+
export const BASH_SPAWN_HOOK_REQUEST_EVENT = 'ad:bash:spawn-hook:request'
|
|
5
|
+
export const TOOLCHAIN_SPAWN_HOOK_CONTRIBUTOR_ID = 'toolchain'
|
|
6
|
+
export const TOOLCHAIN_SPAWN_HOOK_PRIORITY = 100
|
|
7
|
+
|
|
8
|
+
export function formatRewriteSourcePrefix(sourceMode: BashSourceMode): string {
|
|
9
|
+
return `[${sourceMode}]`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type SpawnHookContributor = {
|
|
13
|
+
id: string
|
|
14
|
+
priority?: number
|
|
15
|
+
spawnHook: (ctx: BashSpawnContext) => BashSpawnContext
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SpawnHookRequestPayload = {
|
|
19
|
+
register: (contributor: SpawnHookContributor) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isSpawnHookRequestPayload(
|
|
23
|
+
value: unknown,
|
|
24
|
+
): value is SpawnHookRequestPayload {
|
|
25
|
+
if (!value || typeof value !== 'object') return false
|
|
26
|
+
const v = value as Partial<SpawnHookRequestPayload>
|
|
27
|
+
return typeof v.register === 'function'
|
|
28
|
+
}
|