@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.
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { BashSourceMode } from '../config'
2
+
3
+ export function isValidBashSourceMode(value: unknown): value is BashSourceMode {
4
+ return value === 'override-bash' || value === 'composed-bash'
5
+ }