@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
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
|
+
}
|