@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/src/config.ts ADDED
@@ -0,0 +1,325 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { ConfigLoader, type Migration } from '@aliou/pi-utils-settings'
5
+ import { DEFAULT_PROJECT_TOOLCHAIN_CONFIG } from './project-config'
6
+ import { isValidBashSourceMode } from './utils/bash-source-mode'
7
+ import {
8
+ isMissingBashSourceMode,
9
+ isV0,
10
+ migrateMissingBashSourceMode,
11
+ migrateV0,
12
+ pendingWarnings,
13
+ } from './utils/migration'
14
+
15
+ /**
16
+ * Configuration schema for the toolchain extension.
17
+ *
18
+ * ToolchainConfig is the JSON-backed user-facing schema (all fields optional).
19
+ * ResolvedExtensionConfig is the JSON-backed internal schema.
20
+ * ResolvedToolchainConfig is the final runtime schema after overlaying project
21
+ * toolchain settings derived from mise.toml.
22
+ *
23
+ * Feature modes:
24
+ * - "disabled": feature is off
25
+ * - "rewrite": transparently rewrite matching commands via spawn hook
26
+ * - "block": block matching commands via tool_call hook (bash tool not overridden)
27
+ */
28
+
29
+ export type FeatureMode = 'disabled' | 'rewrite' | 'block'
30
+ export type BashSourceMode = 'override-bash' | 'composed-bash'
31
+ export type PackageManager = 'bun' | 'pnpm' | 'npm'
32
+
33
+ export interface ToolchainConfig {
34
+ version?: string
35
+ enabled?: boolean
36
+ features?: {
37
+ enforcePackageManager?: FeatureMode
38
+ rewritePython?: FeatureMode
39
+ gitRebaseEditor?: FeatureMode
40
+ }
41
+ packageManager?: {
42
+ selected?: PackageManager
43
+ }
44
+ bash?: {
45
+ sourceMode?: BashSourceMode
46
+ }
47
+ ui?: {
48
+ showRewriteNotifications?: boolean
49
+ }
50
+ }
51
+
52
+ export interface ProjectToolchainConfig {
53
+ sourcePath: string | null
54
+ features: {
55
+ enforcePackageManager: FeatureMode
56
+ rewritePython: FeatureMode
57
+ }
58
+ packageManager: {
59
+ selected: PackageManager | null
60
+ }
61
+ }
62
+
63
+ export interface ResolvedExtensionConfig {
64
+ enabled: boolean
65
+ features: {
66
+ gitRebaseEditor: FeatureMode
67
+ }
68
+ bash: {
69
+ sourceMode: BashSourceMode
70
+ }
71
+ ui: {
72
+ showRewriteNotifications: boolean
73
+ }
74
+ }
75
+
76
+ export interface ResolvedToolchainConfig {
77
+ enabled: boolean
78
+ features: {
79
+ enforcePackageManager: FeatureMode
80
+ rewritePython: FeatureMode
81
+ gitRebaseEditor: FeatureMode
82
+ }
83
+ packageManager: {
84
+ selected: PackageManager
85
+ }
86
+ bash: {
87
+ sourceMode: BashSourceMode
88
+ }
89
+ ui: {
90
+ showRewriteNotifications: boolean
91
+ }
92
+ }
93
+
94
+ export const DEFAULT_EXTENSION_CONFIG: ResolvedExtensionConfig = {
95
+ enabled: true,
96
+ features: {
97
+ gitRebaseEditor: 'rewrite',
98
+ },
99
+ bash: {
100
+ sourceMode: 'override-bash',
101
+ },
102
+ ui: {
103
+ showRewriteNotifications: false,
104
+ },
105
+ }
106
+
107
+ export const DEFAULT_CONFIG: ResolvedToolchainConfig = {
108
+ enabled: DEFAULT_EXTENSION_CONFIG.enabled,
109
+ features: {
110
+ enforcePackageManager:
111
+ DEFAULT_PROJECT_TOOLCHAIN_CONFIG.features.enforcePackageManager,
112
+ rewritePython: DEFAULT_PROJECT_TOOLCHAIN_CONFIG.features.rewritePython,
113
+ gitRebaseEditor: DEFAULT_EXTENSION_CONFIG.features.gitRebaseEditor,
114
+ },
115
+ packageManager: {
116
+ selected:
117
+ DEFAULT_PROJECT_TOOLCHAIN_CONFIG.packageManager.selected ?? 'pnpm',
118
+ },
119
+ bash: {
120
+ sourceMode: DEFAULT_EXTENSION_CONFIG.bash.sourceMode,
121
+ },
122
+ ui: {
123
+ showRewriteNotifications:
124
+ DEFAULT_EXTENSION_CONFIG.ui.showRewriteNotifications,
125
+ },
126
+ }
127
+
128
+ export const IGNORED_PROJECT_SETTINGS_WARNING =
129
+ '[toolchain] Ignoring legacy toolchain.json project settings for package-manager and Python rewrites. These settings now come from the nearest mise.toml.'
130
+
131
+ let hasQueuedIgnoredProjectSettingsWarning = false
132
+ let hasQueuedIgnoredLocalConfigWarning = false
133
+
134
+ const migrations: Migration<ToolchainConfig>[] = [
135
+ {
136
+ name: 'v0-to-current',
137
+ shouldRun: (config) => isV0(config),
138
+ run: (config) => migrateV0(config),
139
+ },
140
+ {
141
+ name: 'add-bash-source-mode',
142
+ shouldRun: (config) => isMissingBashSourceMode(config),
143
+ run: (config) => migrateMissingBashSourceMode(config),
144
+ },
145
+ ]
146
+
147
+ function deepMerge(target: object, source: object): void {
148
+ const t = target as Record<string, unknown>
149
+ const s = source as Record<string, unknown>
150
+
151
+ for (const key in s) {
152
+ if (s[key] === undefined) continue
153
+ if (
154
+ typeof s[key] === 'object' &&
155
+ !Array.isArray(s[key]) &&
156
+ s[key] !== null
157
+ ) {
158
+ if (!t[key] || typeof t[key] !== 'object') t[key] = {}
159
+ deepMerge(t[key] as object, s[key] as object)
160
+ } else {
161
+ t[key] = s[key]
162
+ }
163
+ }
164
+ }
165
+
166
+ function mergeToolchainConfigs(
167
+ ...configs: Array<ToolchainConfig | null | undefined>
168
+ ): ToolchainConfig {
169
+ const merged: ToolchainConfig = {}
170
+
171
+ for (const config of configs) {
172
+ if (config) {
173
+ deepMerge(merged, config)
174
+ }
175
+ }
176
+
177
+ return merged
178
+ }
179
+
180
+ export function getIgnoredLegacyProjectSettingsWarning(
181
+ config: ToolchainConfig | null | undefined,
182
+ ): string | null {
183
+ const hasLegacyProjectSettings =
184
+ config?.features?.enforcePackageManager !== undefined ||
185
+ config?.features?.rewritePython !== undefined ||
186
+ config?.packageManager?.selected !== undefined
187
+
188
+ return hasLegacyProjectSettings ? IGNORED_PROJECT_SETTINGS_WARNING : null
189
+ }
190
+
191
+ function queueIgnoredLegacyProjectSettingsWarning(
192
+ config: ToolchainConfig | null | undefined,
193
+ ): void {
194
+ if (hasQueuedIgnoredProjectSettingsWarning) return
195
+
196
+ const warning = getIgnoredLegacyProjectSettingsWarning(config)
197
+ if (!warning) return
198
+
199
+ pendingWarnings.push(warning)
200
+ hasQueuedIgnoredProjectSettingsWarning = true
201
+ }
202
+
203
+ export function findLegacyLocalConfigPath(
204
+ startDir = process.cwd(),
205
+ ): string | null {
206
+ let dir = startDir
207
+ const home = homedir()
208
+
209
+ while (true) {
210
+ if (dir === home) return null
211
+
212
+ const candidate = resolve(dir, '.pi/extensions/toolchain.json')
213
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
214
+ return candidate
215
+ }
216
+
217
+ const parent = dirname(dir)
218
+ if (parent === dir) return null
219
+ dir = parent
220
+ }
221
+ }
222
+
223
+ export function queueIgnoredLegacyLocalConfigWarning(
224
+ startDir = process.cwd(),
225
+ ): void {
226
+ if (hasQueuedIgnoredLocalConfigWarning) return
227
+
228
+ const path = findLegacyLocalConfigPath(startDir)
229
+ if (!path) return
230
+
231
+ pendingWarnings.push(
232
+ `[toolchain] Ignoring legacy project config at ${path}. Project package-manager and Python rewrites now come from the nearest mise.toml.`,
233
+ )
234
+ hasQueuedIgnoredLocalConfigWarning = true
235
+ }
236
+
237
+ function validateResolvedExtensionConfig(
238
+ config: ResolvedExtensionConfig,
239
+ ): ResolvedExtensionConfig {
240
+ if (!isValidBashSourceMode(config.bash.sourceMode)) {
241
+ throw new Error(
242
+ '[toolchain] Invalid config: bash.sourceMode must be "override-bash" or "composed-bash"',
243
+ )
244
+ }
245
+
246
+ return config
247
+ }
248
+
249
+ function validateResolvedConfig(
250
+ config: ResolvedToolchainConfig,
251
+ ): ResolvedToolchainConfig {
252
+ validateResolvedExtensionConfig({
253
+ enabled: config.enabled,
254
+ features: {
255
+ gitRebaseEditor: config.features.gitRebaseEditor,
256
+ },
257
+ bash: config.bash,
258
+ ui: config.ui,
259
+ })
260
+
261
+ return config
262
+ }
263
+
264
+ export function resolveExtensionConfig(
265
+ config: ToolchainConfig | null | undefined,
266
+ ): ResolvedExtensionConfig {
267
+ return validateResolvedExtensionConfig({
268
+ enabled: config?.enabled ?? DEFAULT_EXTENSION_CONFIG.enabled,
269
+ features: {
270
+ gitRebaseEditor:
271
+ config?.features?.gitRebaseEditor ??
272
+ DEFAULT_EXTENSION_CONFIG.features.gitRebaseEditor,
273
+ },
274
+ bash: {
275
+ sourceMode:
276
+ config?.bash?.sourceMode ?? DEFAULT_EXTENSION_CONFIG.bash.sourceMode,
277
+ },
278
+ ui: {
279
+ showRewriteNotifications:
280
+ config?.ui?.showRewriteNotifications ??
281
+ DEFAULT_EXTENSION_CONFIG.ui.showRewriteNotifications,
282
+ },
283
+ })
284
+ }
285
+
286
+ export function resolveRuntimeConfig(
287
+ extensionConfig: ResolvedExtensionConfig,
288
+ projectConfig: ProjectToolchainConfig = DEFAULT_PROJECT_TOOLCHAIN_CONFIG,
289
+ ): ResolvedToolchainConfig {
290
+ return validateResolvedConfig({
291
+ enabled: extensionConfig.enabled,
292
+ features: {
293
+ enforcePackageManager: projectConfig.features.enforcePackageManager,
294
+ rewritePython: projectConfig.features.rewritePython,
295
+ gitRebaseEditor: extensionConfig.features.gitRebaseEditor,
296
+ },
297
+ packageManager: {
298
+ selected:
299
+ projectConfig.packageManager.selected ??
300
+ DEFAULT_CONFIG.packageManager.selected,
301
+ },
302
+ bash: extensionConfig.bash,
303
+ ui: extensionConfig.ui,
304
+ })
305
+ }
306
+
307
+ /** @deprecated Use resolveExtensionConfig for JSON-backed settings or resolveRuntimeConfig for final runtime config. */
308
+ export function resolveToolchainConfig(
309
+ config: ToolchainConfig | null | undefined,
310
+ ): ResolvedExtensionConfig {
311
+ return resolveExtensionConfig(config)
312
+ }
313
+
314
+ export const configLoader = new ConfigLoader<
315
+ ToolchainConfig,
316
+ ResolvedExtensionConfig
317
+ >('toolchain', DEFAULT_EXTENSION_CONFIG, {
318
+ scopes: ['global', 'memory'],
319
+ migrations,
320
+ afterMerge: (_resolved, global, _local, memory) => {
321
+ const merged = mergeToolchainConfigs(global, memory)
322
+ queueIgnoredLegacyProjectSettingsWarning(merged)
323
+ return resolveExtensionConfig(merged)
324
+ },
325
+ })
@@ -0,0 +1,35 @@
1
+ import {
2
+ createBashTool,
3
+ type ExtensionAPI,
4
+ } from '@mariozechner/pi-coding-agent'
5
+ import type { ResolvedToolchainConfig } from '../config'
6
+ import { createSpawnHook } from '../rewriters'
7
+ import {
8
+ BASH_SPAWN_HOOK_REQUEST_EVENT,
9
+ isSpawnHookRequestPayload,
10
+ TOOLCHAIN_SPAWN_HOOK_CONTRIBUTOR_ID,
11
+ TOOLCHAIN_SPAWN_HOOK_PRIORITY,
12
+ } from '../utils/bash-composition'
13
+
14
+ export function registerBashIntegration(
15
+ pi: ExtensionAPI,
16
+ config: ResolvedToolchainConfig,
17
+ ): void {
18
+ const spawnHook = createSpawnHook(config)
19
+
20
+ if (config.bash.sourceMode === 'composed-bash') {
21
+ pi.events.on(BASH_SPAWN_HOOK_REQUEST_EVENT, (data: unknown) => {
22
+ if (!isSpawnHookRequestPayload(data)) return
23
+
24
+ data.register({
25
+ id: TOOLCHAIN_SPAWN_HOOK_CONTRIBUTOR_ID,
26
+ priority: TOOLCHAIN_SPAWN_HOOK_PRIORITY,
27
+ spawnHook,
28
+ })
29
+ })
30
+ return
31
+ }
32
+
33
+ const bashTool = createBashTool(process.cwd(), { spawnHook })
34
+ pi.registerTool({ ...bashTool })
35
+ }
@@ -0,0 +1,42 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'
2
+ import type { ResolvedToolchainConfig } from '../config'
3
+ import { analyzeRewrite } from '../rewriters'
4
+ import { formatRewriteSourcePrefix } from '../utils/bash-composition'
5
+
6
+ export function registerRewriteNotifications(
7
+ pi: ExtensionAPI,
8
+ config: ResolvedToolchainConfig,
9
+ ): void {
10
+ if (!config.ui.showRewriteNotifications) return
11
+
12
+ pi.on('tool_call', async (event, ctx) => {
13
+ if (event.toolName !== 'bash') return
14
+
15
+ const command = String(event.input.command ?? '')
16
+ if (!command) return
17
+
18
+ const rewriteResult = analyzeRewrite(
19
+ {
20
+ command,
21
+ cwd: process.cwd(),
22
+ env: process.env,
23
+ },
24
+ config,
25
+ )
26
+
27
+ const prefix = formatRewriteSourcePrefix(config.bash.sourceMode)
28
+ for (const notice of rewriteResult.notices) {
29
+ ctx.ui.notify(`${prefix} ${notice.message}`, 'warning')
30
+ }
31
+
32
+ return undefined
33
+ })
34
+ }
35
+
36
+ export function hasRewriteFeatures(config: ResolvedToolchainConfig): boolean {
37
+ return (
38
+ config.features.enforcePackageManager === 'rewrite' ||
39
+ config.features.rewritePython === 'rewrite' ||
40
+ config.features.gitRebaseEditor === 'rewrite'
41
+ )
42
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'
2
+ import { pendingWarnings } from '../utils/migration'
3
+
4
+ export function registerSessionStartWarnings(pi: ExtensionAPI): void {
5
+ pi.on('session_start', (_event, ctx) => {
6
+ for (const warning of pendingWarnings.splice(0)) {
7
+ ctx.ui.notify(warning, 'warning')
8
+ }
9
+ })
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'
2
+ import { setupBlockers } from './blockers'
3
+ import { registerToolchainSettings } from './commands/settings-command'
4
+ import {
5
+ configLoader,
6
+ queueIgnoredLegacyLocalConfigWarning,
7
+ resolveRuntimeConfig,
8
+ } from './config'
9
+ import { registerBashIntegration } from './hooks/bash-integration'
10
+ import {
11
+ hasRewriteFeatures,
12
+ registerRewriteNotifications,
13
+ } from './hooks/rewrite-notifications'
14
+ import { registerSessionStartWarnings } from './hooks/session-start'
15
+ import { findProjectToolchainConfig } from './project-config'
16
+
17
+ /**
18
+ * Toolchain Extension
19
+ *
20
+ * Enforces opinionated toolchain preferences per feature, each independently
21
+ * set to one of three modes:
22
+ *
23
+ * - "disabled": no action taken
24
+ * - "rewrite": transparently rewrite matching commands via spawn hook
25
+ * - "block": block commands via tool_call hook
26
+ *
27
+ * Configuration:
28
+ * - Global settings: ~/.pi/agent/extensions/toolchain.json
29
+ * - Memory settings: session-only overrides via /toolchain:settings
30
+ * - Project toolchain: nearest mise.toml
31
+ */
32
+ export default async function (pi: ExtensionAPI) {
33
+ queueIgnoredLegacyLocalConfigWarning()
34
+ await configLoader.load()
35
+ const extensionConfig = configLoader.getConfig()
36
+ const projectConfig = await findProjectToolchainConfig()
37
+ const config = resolveRuntimeConfig(extensionConfig, projectConfig)
38
+ if (!config.enabled) return
39
+
40
+ registerToolchainSettings(pi)
41
+ registerSessionStartWarnings(pi)
42
+ setupBlockers(pi, config)
43
+ registerRewriteNotifications(pi, config)
44
+
45
+ if (!hasRewriteFeatures(config)) return
46
+ registerBashIntegration(pi, config)
47
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { parse as parseToml } from '@iarna/toml'
6
+ import type { PackageManager, ProjectToolchainConfig } from './config'
7
+
8
+ const SUPPORTED_PACKAGE_MANAGERS: PackageManager[] = ['bun', 'pnpm', 'npm']
9
+
10
+ export const DEFAULT_PROJECT_TOOLCHAIN_CONFIG: ProjectToolchainConfig = {
11
+ sourcePath: null,
12
+ features: {
13
+ enforcePackageManager: 'disabled',
14
+ rewritePython: 'disabled',
15
+ },
16
+ packageManager: {
17
+ selected: null,
18
+ },
19
+ }
20
+
21
+ function createDefaultProjectToolchainConfig(): ProjectToolchainConfig {
22
+ return structuredClone(DEFAULT_PROJECT_TOOLCHAIN_CONFIG)
23
+ }
24
+
25
+ function findNearestMiseTomlPath(startDir: string): string | null {
26
+ let dir = startDir
27
+ const home = homedir()
28
+
29
+ while (true) {
30
+ if (dir === home) return null
31
+
32
+ const candidate = join(dir, 'mise.toml')
33
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
34
+ return candidate
35
+ }
36
+
37
+ const parent = dirname(dir)
38
+ if (parent === dir) return null
39
+ dir = parent
40
+ }
41
+ }
42
+
43
+ function getToolsTable(parsed: unknown): Record<string, unknown> {
44
+ if (!parsed || typeof parsed !== 'object') return {}
45
+
46
+ const tools = (parsed as { tools?: unknown }).tools
47
+ if (!tools || typeof tools !== 'object' || Array.isArray(tools)) {
48
+ return {}
49
+ }
50
+
51
+ return tools as Record<string, unknown>
52
+ }
53
+
54
+ function hasTool(tools: Record<string, unknown>, name: string): boolean {
55
+ return Object.hasOwn(tools, name)
56
+ }
57
+
58
+ function deriveProjectToolchainConfig(
59
+ sourcePath: string,
60
+ rawConfig: string,
61
+ ): ProjectToolchainConfig {
62
+ const parsed = parseToml(rawConfig)
63
+ const tools = getToolsTable(parsed)
64
+ const hasUv = hasTool(tools, 'uv')
65
+ const packageManagers = SUPPORTED_PACKAGE_MANAGERS.filter((manager) =>
66
+ hasTool(tools, manager),
67
+ )
68
+
69
+ return {
70
+ sourcePath,
71
+ features: {
72
+ rewritePython: hasUv ? 'rewrite' : 'disabled',
73
+ enforcePackageManager:
74
+ packageManagers.length === 1 ? 'rewrite' : 'disabled',
75
+ },
76
+ packageManager: {
77
+ selected: packageManagers.length === 1 ? packageManagers[0] : null,
78
+ },
79
+ }
80
+ }
81
+
82
+ export async function findProjectToolchainConfig(
83
+ startDir = process.cwd(),
84
+ ): Promise<ProjectToolchainConfig> {
85
+ const sourcePath = findNearestMiseTomlPath(startDir)
86
+ if (!sourcePath) {
87
+ return createDefaultProjectToolchainConfig()
88
+ }
89
+
90
+ try {
91
+ const rawConfig = await readFile(sourcePath, 'utf8')
92
+ return deriveProjectToolchainConfig(sourcePath, rawConfig)
93
+ } catch {
94
+ const fallback = createDefaultProjectToolchainConfig()
95
+ fallback.sourcePath = sourcePath
96
+ return fallback
97
+ }
98
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Git rebase editor rewriter.
3
+ *
4
+ * Injects GIT_EDITOR and GIT_SEQUENCE_EDITOR env vars for git rebase
5
+ * commands so they run non-interactively without opening an editor.
6
+ *
7
+ * - GIT_EDITOR=true: prevents commit message editor (rebase --continue)
8
+ * - GIT_SEQUENCE_EDITOR=: accepts default rebase sequence (interactive rebase)
9
+ *
10
+ * Skips injection if the command already has editor configuration via
11
+ * AST assignments or existing env vars.
12
+ */
13
+
14
+ import { parse } from '@aliou/sh'
15
+ import { walkCommandsWithAssignments, wordToString } from '../utils/shell-utils'
16
+ import type { Rewriter } from './types'
17
+
18
+ export function createGitRebaseRewriter(): Rewriter {
19
+ return (ctx) => {
20
+ let needsEditor = false
21
+
22
+ try {
23
+ const { ast } = parse(ctx.command)
24
+
25
+ walkCommandsWithAssignments(ast, (cmd, assignments) => {
26
+ const words = (cmd.words ?? []).map(wordToString)
27
+ if (words[0] !== 'git' || words[1] !== 'rebase') return
28
+
29
+ // Skip if already configured via inline assignments
30
+ if (hasEditorAssignment(assignments)) return
31
+
32
+ needsEditor = true
33
+ return true
34
+ })
35
+ } catch {
36
+ // Fallback: check raw string for git rebase pattern
37
+ if (/\bgit\s+rebase\b/.test(ctx.command)) {
38
+ // Skip if already has editor config
39
+ if (!/GIT_SEQUENCE_EDITOR|GIT_EDITOR|core\.editor/.test(ctx.command)) {
40
+ needsEditor = true
41
+ }
42
+ }
43
+ }
44
+
45
+ if (!needsEditor) return { ctx, notices: [] }
46
+
47
+ // Skip if env vars already set in the context
48
+ if (ctx.env.GIT_EDITOR || ctx.env.GIT_SEQUENCE_EDITOR) {
49
+ return { ctx, notices: [] }
50
+ }
51
+
52
+ return {
53
+ ctx: {
54
+ ...ctx,
55
+ env: {
56
+ ...ctx.env,
57
+ GIT_EDITOR: 'true',
58
+ GIT_SEQUENCE_EDITOR: ':',
59
+ },
60
+ },
61
+ notices: [
62
+ {
63
+ message:
64
+ 'Rewrote command behavior: injected GIT_EDITOR=true and GIT_SEQUENCE_EDITOR=: for git rebase',
65
+ },
66
+ ],
67
+ }
68
+ }
69
+ }
70
+
71
+ function hasEditorAssignment(assignments: { name: string }[]): boolean {
72
+ return assignments.some(
73
+ (a) => a.name === 'GIT_SEQUENCE_EDITOR' || a.name === 'GIT_EDITOR',
74
+ )
75
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Composes individual rewriters into a single BashSpawnHook and exposes
3
+ * rewrite analysis for optional UI notifications.
4
+ */
5
+
6
+ import type { BashSpawnContext } from '@mariozechner/pi-coding-agent'
7
+ import type { ResolvedToolchainConfig } from '../config'
8
+ import { createGitRebaseRewriter } from './git-rebase'
9
+ import { createPackageManagerRewriter } from './package-manager'
10
+ import { createPythonRewriter } from './python'
11
+ import type { RewriteNotice, Rewriter } from './types'
12
+
13
+ function createRewriters(config: ResolvedToolchainConfig): Rewriter[] {
14
+ const rewriters: Rewriter[] = []
15
+
16
+ if (config.features.enforcePackageManager === 'rewrite') {
17
+ rewriters.push(createPackageManagerRewriter(config))
18
+ }
19
+ if (config.features.rewritePython === 'rewrite') {
20
+ rewriters.push(createPythonRewriter())
21
+ }
22
+ if (config.features.gitRebaseEditor === 'rewrite') {
23
+ rewriters.push(createGitRebaseRewriter())
24
+ }
25
+
26
+ return rewriters
27
+ }
28
+
29
+ export function analyzeRewrite(
30
+ ctx: BashSpawnContext,
31
+ config: ResolvedToolchainConfig,
32
+ ): { ctx: BashSpawnContext; notices: RewriteNotice[] } {
33
+ let result = ctx
34
+ const notices: RewriteNotice[] = []
35
+
36
+ for (const rewrite of createRewriters(config)) {
37
+ const rewriteResult = rewrite(result)
38
+ result = rewriteResult.ctx
39
+ notices.push(...rewriteResult.notices)
40
+ }
41
+
42
+ return { ctx: result, notices }
43
+ }
44
+
45
+ export function createSpawnHook(
46
+ config: ResolvedToolchainConfig,
47
+ ): (ctx: BashSpawnContext) => BashSpawnContext {
48
+ return (ctx) => analyzeRewrite(ctx, config).ctx
49
+ }