@wingsbutterfly/dsh-rtk 0.1.0
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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/README.zh.md +211 -0
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/docs/assets/how-it-works.svg +47 -0
- package/docs/verification.md +279 -0
- package/lib/command.d.ts +41 -0
- package/lib/command.d.ts.map +1 -0
- package/lib/command.js +137 -0
- package/lib/command.js.map +1 -0
- package/lib/compact/build.d.ts +15 -0
- package/lib/compact/build.d.ts.map +1 -0
- package/lib/compact/build.js +140 -0
- package/lib/compact/build.js.map +1 -0
- package/lib/compact/detect.d.ts +22 -0
- package/lib/compact/detect.d.ts.map +1 -0
- package/lib/compact/detect.js +54 -0
- package/lib/compact/detect.js.map +1 -0
- package/lib/compact/dsh-result.d.ts +49 -0
- package/lib/compact/dsh-result.d.ts.map +1 -0
- package/lib/compact/dsh-result.js +84 -0
- package/lib/compact/dsh-result.js.map +1 -0
- package/lib/compact/git.d.ts +21 -0
- package/lib/compact/git.d.ts.map +1 -0
- package/lib/compact/git.js +197 -0
- package/lib/compact/git.js.map +1 -0
- package/lib/compact/index.d.ts +39 -0
- package/lib/compact/index.d.ts.map +1 -0
- package/lib/compact/index.js +236 -0
- package/lib/compact/index.js.map +1 -0
- package/lib/compact/linter.d.ts +12 -0
- package/lib/compact/linter.d.ts.map +1 -0
- package/lib/compact/linter.js +118 -0
- package/lib/compact/linter.js.map +1 -0
- package/lib/compact/search.d.ts +16 -0
- package/lib/compact/search.d.ts.map +1 -0
- package/lib/compact/search.js +67 -0
- package/lib/compact/search.js.map +1 -0
- package/lib/compact/source.d.ts +22 -0
- package/lib/compact/source.d.ts.map +1 -0
- package/lib/compact/source.js +224 -0
- package/lib/compact/source.js.map +1 -0
- package/lib/compact/test-output.d.ts +12 -0
- package/lib/compact/test-output.d.ts.map +1 -0
- package/lib/compact/test-output.js +168 -0
- package/lib/compact/test-output.js.map +1 -0
- package/lib/compact/text.d.ts +22 -0
- package/lib/compact/text.d.ts.map +1 -0
- package/lib/compact/text.js +87 -0
- package/lib/compact/text.js.map +1 -0
- package/lib/config.d.ts +243 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +183 -0
- package/lib/config.js.map +1 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +405 -0
- package/lib/index.js.map +1 -0
- package/lib/metrics.d.ts +35 -0
- package/lib/metrics.d.ts.map +1 -0
- package/lib/metrics.js +51 -0
- package/lib/metrics.js.map +1 -0
- package/lib/rtk-executable.d.ts +51 -0
- package/lib/rtk-executable.d.ts.map +1 -0
- package/lib/rtk-executable.js +75 -0
- package/lib/rtk-executable.js.map +1 -0
- package/lib/rtk-rewrite.d.ts +88 -0
- package/lib/rtk-rewrite.d.ts.map +1 -0
- package/lib/rtk-rewrite.js +150 -0
- package/lib/rtk-rewrite.js.map +1 -0
- package/lib/runtime-guard.d.ts +31 -0
- package/lib/runtime-guard.d.ts.map +1 -0
- package/lib/runtime-guard.js +32 -0
- package/lib/runtime-guard.js.map +1 -0
- package/package.json +82 -0
- package/scripts/link-dsh.mjs +135 -0
- package/src/command.ts +174 -0
- package/src/compact/build.ts +154 -0
- package/src/compact/detect.ts +54 -0
- package/src/compact/dsh-result.ts +99 -0
- package/src/compact/git.ts +209 -0
- package/src/compact/index.ts +284 -0
- package/src/compact/linter.ts +126 -0
- package/src/compact/search.ts +73 -0
- package/src/compact/source.ts +244 -0
- package/src/compact/test-output.ts +184 -0
- package/src/compact/text.ts +86 -0
- package/src/config.ts +263 -0
- package/src/index.ts +431 -0
- package/src/metrics.ts +84 -0
- package/src/rtk-executable.ts +117 -0
- package/src/rtk-rewrite.ts +179 -0
- package/src/runtime-guard.ts +44 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { RtkConfig } from './config.js'
|
|
2
|
+
import type { MetricsSummary } from './metrics.js'
|
|
3
|
+
import type { RtkRuntimeStatus } from './runtime-guard.js'
|
|
4
|
+
|
|
5
|
+
/** Everything the `/rtk` command needs from the plugin. */
|
|
6
|
+
export interface RtkCommandController {
|
|
7
|
+
getConfig(): RtkConfig
|
|
8
|
+
/** Replace the user layer wholesale; `{}` restores composition defaults. */
|
|
9
|
+
resetConfig(): Promise<void>
|
|
10
|
+
getRuntimeStatus(): RtkRuntimeStatus
|
|
11
|
+
refreshRuntimeStatus(): Promise<RtkRuntimeStatus>
|
|
12
|
+
getMetrics(): MetricsSummary
|
|
13
|
+
clearMetrics(): void
|
|
14
|
+
/** Where the user layer of the configuration lives, when it is persisted. */
|
|
15
|
+
configLocation(): string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The command result shape the harness normalizes. */
|
|
19
|
+
export type RtkCommandResult = { kind: 'success'; text?: string } | { kind: 'error'; text: string }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Official install routes, taken from the project's own README.
|
|
23
|
+
*
|
|
24
|
+
* `cargo install rtk` is called out because crates.io carries an unrelated
|
|
25
|
+
* crate of the same name; only the `--git` form installs this tool.
|
|
26
|
+
*/
|
|
27
|
+
const INSTALL_HINT = [
|
|
28
|
+
'Install rtk with one of:',
|
|
29
|
+
' brew install rtk # macOS / Linux (Homebrew, recommended)',
|
|
30
|
+
' winget install rtk-ai.rtk # Windows',
|
|
31
|
+
' curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh',
|
|
32
|
+
' cargo install --git https://github.com/rtk-ai/rtk',
|
|
33
|
+
'',
|
|
34
|
+
'Note: plain `cargo install rtk` installs an unrelated crate of the same name.',
|
|
35
|
+
'Releases: https://github.com/rtk-ai/rtk/releases',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
const HELP_TEXT = [
|
|
39
|
+
'dsh-rtk — rewrite bash commands to rtk and compact tool output.',
|
|
40
|
+
'',
|
|
41
|
+
' /rtk show configuration and runtime status',
|
|
42
|
+
' /rtk show same as /rtk',
|
|
43
|
+
' /rtk path where the configuration is stored',
|
|
44
|
+
' /rtk verify check whether the rtk executable is usable (prints install options when it is not)',
|
|
45
|
+
' /rtk stats compaction savings for this session',
|
|
46
|
+
' /rtk clear-stats reset the savings counters',
|
|
47
|
+
' /rtk reset restore configuration defaults',
|
|
48
|
+
' /rtk help this text',
|
|
49
|
+
].join('\n')
|
|
50
|
+
|
|
51
|
+
function yesNo(value: boolean): string {
|
|
52
|
+
return value ? 'on' : 'off'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function configReport(config: RtkConfig, status: RtkRuntimeStatus, location: string): string {
|
|
56
|
+
const compaction = config.outputCompaction
|
|
57
|
+
const lines = [
|
|
58
|
+
`dsh-rtk: ${yesNo(config.enabled)} mode: ${config.mode}`,
|
|
59
|
+
`rtk binary: ${status.rtkAvailable ? 'available' : 'unavailable'}${status.rtkExecutablePath ? ` (${status.rtkExecutablePath})` : ''}`,
|
|
60
|
+
`config: ${location}`,
|
|
61
|
+
'',
|
|
62
|
+
'command rewriting',
|
|
63
|
+
` guardWhenRtkMissing ${yesNo(config.guardWhenRtkMissing)}`,
|
|
64
|
+
` showRewriteNotifications ${yesNo(config.showRewriteNotifications)}`,
|
|
65
|
+
` rewriteTimeoutMs ${config.rewriteTimeoutMs}`,
|
|
66
|
+
'',
|
|
67
|
+
'output compaction',
|
|
68
|
+
` enabled ${yesNo(compaction.enabled)}`,
|
|
69
|
+
` tools ${config.compactedTools.join(', ')}`,
|
|
70
|
+
` stripAnsi ${yesNo(compaction.stripAnsi)}`,
|
|
71
|
+
` readCompaction ${yesNo(compaction.readCompaction.enabled)}`,
|
|
72
|
+
` sourceCodeFiltering ${compaction.sourceCodeFiltering}`,
|
|
73
|
+
` filterBuildOutput ${yesNo(compaction.filterBuildOutput)}`,
|
|
74
|
+
` aggregateTestOutput ${yesNo(compaction.aggregateTestOutput)}`,
|
|
75
|
+
` compactGitOutput ${yesNo(compaction.compactGitOutput)}`,
|
|
76
|
+
` aggregateLinterOutput ${yesNo(compaction.aggregateLinterOutput)}`,
|
|
77
|
+
` groupSearchOutput ${yesNo(compaction.groupSearchOutput)}`,
|
|
78
|
+
` smartTruncate ${yesNo(compaction.smartTruncate.enabled)} (max ${compaction.smartTruncate.maxLines} lines)`,
|
|
79
|
+
` truncate ${yesNo(compaction.truncate.enabled)} (max ${compaction.truncate.maxChars} chars)`,
|
|
80
|
+
]
|
|
81
|
+
if (status.lastError) lines.push('', `last rtk error: ${status.lastError}`)
|
|
82
|
+
return lines.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function statsReport(summary: MetricsSummary): string {
|
|
86
|
+
if (summary.calls === 0) return 'dsh-rtk: no compaction recorded in this session yet.'
|
|
87
|
+
|
|
88
|
+
const lines = [
|
|
89
|
+
`compacted calls: ${summary.calls}`,
|
|
90
|
+
`characters: ${summary.originalChars} -> ${summary.compactedChars} (saved ${summary.savedChars}, ${summary.savedPercent}%)`,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
const tools = Object.entries(summary.byTool)
|
|
94
|
+
if (tools.length > 0) {
|
|
95
|
+
lines.push('', 'by tool:')
|
|
96
|
+
for (const [tool, totals] of tools.sort((left, right) => right[1].originalChars - left[1].originalChars)) {
|
|
97
|
+
const saved = Math.max(0, totals.originalChars - totals.compactedChars)
|
|
98
|
+
lines.push(` ${tool}: ${totals.calls} call(s), saved ${saved} chars`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const techniques = Object.entries(summary.byTechnique)
|
|
103
|
+
if (techniques.length > 0) {
|
|
104
|
+
lines.push('', 'techniques:')
|
|
105
|
+
for (const [technique, count] of techniques.sort((left, right) => right[1] - left[1])) {
|
|
106
|
+
lines.push(` ${technique}: ${count}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return lines.join('\n')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the `/rtk` command definition.
|
|
115
|
+
*
|
|
116
|
+
* The harness has no interactive settings modal, so every subcommand returns
|
|
117
|
+
* text; the configuration itself is editable through the harness settings
|
|
118
|
+
* document, which this namespace is registered in.
|
|
119
|
+
*
|
|
120
|
+
* @param controller - accessors into the live plugin state.
|
|
121
|
+
* @returns the command definition to register.
|
|
122
|
+
*/
|
|
123
|
+
export function createRtkCommand(controller: RtkCommandController): {
|
|
124
|
+
name: string
|
|
125
|
+
description: string
|
|
126
|
+
handler: (invocation: { rawInput: string }) => Promise<RtkCommandResult>
|
|
127
|
+
} {
|
|
128
|
+
return {
|
|
129
|
+
name: 'rtk',
|
|
130
|
+
description: 'RTK command rewriting and output compaction: status, verification, and savings.',
|
|
131
|
+
async handler(invocation) {
|
|
132
|
+
const subcommand = invocation.rawInput.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
|
133
|
+
|
|
134
|
+
switch (subcommand) {
|
|
135
|
+
case '':
|
|
136
|
+
case 'show':
|
|
137
|
+
return { kind: 'success', text: configReport(controller.getConfig(), controller.getRuntimeStatus(), controller.configLocation()) }
|
|
138
|
+
|
|
139
|
+
case 'path':
|
|
140
|
+
return { kind: 'success', text: controller.configLocation() }
|
|
141
|
+
|
|
142
|
+
case 'verify': {
|
|
143
|
+
const status = await controller.refreshRuntimeStatus()
|
|
144
|
+
if (status.rtkAvailable) {
|
|
145
|
+
const parts = [`rtk is available at ${status.rtkExecutablePath ?? status.rtkExecutableCommand ?? 'rtk'}`]
|
|
146
|
+
if (status.rtkExecutableResolutionWarning) parts.push(`note: ${status.rtkExecutableResolutionWarning}`)
|
|
147
|
+
return { kind: 'success', text: parts.join('\n') }
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
kind: 'error',
|
|
151
|
+
text: `rtk is not usable: ${status.lastError ?? 'unknown error'}\n\n${INSTALL_HINT}`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'stats':
|
|
156
|
+
return { kind: 'success', text: statsReport(controller.getMetrics()) }
|
|
157
|
+
|
|
158
|
+
case 'clear-stats':
|
|
159
|
+
controller.clearMetrics()
|
|
160
|
+
return { kind: 'success', text: 'dsh-rtk: savings counters cleared.' }
|
|
161
|
+
|
|
162
|
+
case 'reset':
|
|
163
|
+
await controller.resetConfig()
|
|
164
|
+
return { kind: 'success', text: 'dsh-rtk: configuration restored to defaults.' }
|
|
165
|
+
|
|
166
|
+
case 'help':
|
|
167
|
+
return { kind: 'success', text: HELP_TEXT }
|
|
168
|
+
|
|
169
|
+
default:
|
|
170
|
+
return { kind: 'error', text: `unknown subcommand "${subcommand}".\n\n${HELP_TEXT}` }
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { matchesCommandPatterns } from './detect.js'
|
|
2
|
+
|
|
3
|
+
interface BuildStats {
|
|
4
|
+
compiled: number
|
|
5
|
+
errors: string[][]
|
|
6
|
+
warnings: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BUILD_COMMAND_PATTERNS = [
|
|
10
|
+
/^cargo\s+(build|check)\b/,
|
|
11
|
+
/^bun\s+build\b/,
|
|
12
|
+
/^npm\s+run\s+build\b/,
|
|
13
|
+
/^yarn\s+build\b/,
|
|
14
|
+
/^pnpm\s+build\b/,
|
|
15
|
+
/^(?:npx\s+)?tsc\b/,
|
|
16
|
+
/^make\b/,
|
|
17
|
+
/^cmake\b/,
|
|
18
|
+
/^gradle\b/,
|
|
19
|
+
/^mvn\b/,
|
|
20
|
+
/^go\s+(build|install)\b/,
|
|
21
|
+
/^python\s+setup\.py\s+build\b/,
|
|
22
|
+
/^pip\s+install\b/,
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
/** Progress chatter that carries no actionable information. */
|
|
26
|
+
const SKIP_PATTERNS = [
|
|
27
|
+
/^\s*Compiling\s+/,
|
|
28
|
+
/^\s*Checking\s+/,
|
|
29
|
+
/^\s*Downloading\s+/,
|
|
30
|
+
/^\s*Downloaded\s+/,
|
|
31
|
+
/^\s*Fetching\s+/,
|
|
32
|
+
/^\s*Fetched\s+/,
|
|
33
|
+
/^\s*Updating\s+/,
|
|
34
|
+
/^\s*Updated\s+/,
|
|
35
|
+
/^\s*Building\s+/,
|
|
36
|
+
/^\s*Generated\s+/,
|
|
37
|
+
/^\s*Creating\s+/,
|
|
38
|
+
/^\s*Running\s+/,
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const ERROR_START_PATTERNS = [
|
|
42
|
+
/^error\[/,
|
|
43
|
+
/^error:/,
|
|
44
|
+
/^\[ERROR\]/,
|
|
45
|
+
/^FAIL/,
|
|
46
|
+
// `tsc` and friends: `path(line,col): error TSxxxx: message`.
|
|
47
|
+
/^[^\s:][^:]*(?:\(\d+[,:]\d+\)|:\d+:\d+):\s*(?:fatal\s+)?error\b/,
|
|
48
|
+
// The `path:line:col: error: message` shape used by many linters and compilers.
|
|
49
|
+
/^[^\s:][^:]*:\d+:\s*(?:fatal\s+)?error\b/,
|
|
50
|
+
]
|
|
51
|
+
const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/]
|
|
52
|
+
const COMPILE_PROGRESS_PATTERN = /^\s*(Compiling|Checking|Building)\s+/
|
|
53
|
+
|
|
54
|
+
const MAX_ERRORS = 5
|
|
55
|
+
const MAX_ERROR_LINES = 10
|
|
56
|
+
const BLANKS_TO_CLOSE_BLOCK = 2
|
|
57
|
+
|
|
58
|
+
function isSkipLine(line: string): boolean {
|
|
59
|
+
return SKIP_PATTERNS.some((pattern) => pattern.test(line))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reduce compiler output to its errors and warnings.
|
|
64
|
+
*
|
|
65
|
+
* Errors keep their indented continuation lines (the source excerpt and the
|
|
66
|
+
* `-->` location) because that detail is what makes an error actionable;
|
|
67
|
+
* progress lines and everything else are dropped. A build with no diagnostics
|
|
68
|
+
* collapses to a one-line success marker, which is the common case and the
|
|
69
|
+
* largest saving.
|
|
70
|
+
*
|
|
71
|
+
* @param output - raw build output.
|
|
72
|
+
* @param command - the command that produced it; drives the applicability check.
|
|
73
|
+
* @returns the summary, or `null` when the command is not a build command.
|
|
74
|
+
*/
|
|
75
|
+
export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
|
|
76
|
+
if (!matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS)) return null
|
|
77
|
+
|
|
78
|
+
const lines = output.split('\n')
|
|
79
|
+
const stats: BuildStats = { compiled: 0, errors: [], warnings: [] }
|
|
80
|
+
|
|
81
|
+
let inErrorBlock = false
|
|
82
|
+
let currentError: string[] = []
|
|
83
|
+
let blankCount = 0
|
|
84
|
+
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (COMPILE_PROGRESS_PATTERN.test(line)) {
|
|
87
|
+
stats.compiled++
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (isSkipLine(line)) continue
|
|
91
|
+
|
|
92
|
+
if (ERROR_START_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
93
|
+
if (inErrorBlock && currentError.length > 0) stats.errors.push([...currentError])
|
|
94
|
+
inErrorBlock = true
|
|
95
|
+
currentError = [line]
|
|
96
|
+
blankCount = 0
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (WARNING_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
101
|
+
stats.warnings.push(line)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!inErrorBlock) continue
|
|
106
|
+
|
|
107
|
+
if (line.trim() === '') {
|
|
108
|
+
blankCount++
|
|
109
|
+
if (blankCount >= BLANKS_TO_CLOSE_BLOCK && currentError.length > 3) {
|
|
110
|
+
stats.errors.push([...currentError])
|
|
111
|
+
inErrorBlock = false
|
|
112
|
+
currentError = []
|
|
113
|
+
} else {
|
|
114
|
+
currentError.push(line)
|
|
115
|
+
}
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (line.match(/^\s/) || line.match(/^-->/)) {
|
|
120
|
+
currentError.push(line)
|
|
121
|
+
blankCount = 0
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stats.errors.push([...currentError])
|
|
126
|
+
inErrorBlock = false
|
|
127
|
+
currentError = []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (inErrorBlock && currentError.length > 0) stats.errors.push(currentError)
|
|
131
|
+
|
|
132
|
+
if (stats.errors.length === 0 && stats.warnings.length === 0) {
|
|
133
|
+
// Success may only be claimed on positive evidence that the build ran and
|
|
134
|
+
// was clean. With no diagnostics AND no progress lines the parser simply
|
|
135
|
+
// did not understand the output — and it cannot see an exit code, so
|
|
136
|
+
// guessing "success" would replace a real compiler failure with a
|
|
137
|
+
// reassuring lie. Declining to summarize leaves the text intact.
|
|
138
|
+
if (stats.compiled === 0) return null
|
|
139
|
+
return `[OK] Build successful (${stats.compiled} units compiled)`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result: string[] = []
|
|
143
|
+
if (stats.errors.length > 0) {
|
|
144
|
+
result.push(`[ERROR] ${stats.errors.length} error(s):`)
|
|
145
|
+
for (const error of stats.errors.slice(0, MAX_ERRORS)) {
|
|
146
|
+
result.push(...error.slice(0, MAX_ERROR_LINES))
|
|
147
|
+
if (error.length > MAX_ERROR_LINES) result.push(' ...')
|
|
148
|
+
}
|
|
149
|
+
if (stats.errors.length > MAX_ERRORS) result.push(`... and ${stats.errors.length - MAX_ERRORS} more errors`)
|
|
150
|
+
}
|
|
151
|
+
if (stats.warnings.length > 0) result.push(`\n[WARN] ${stats.warnings.length} warning(s)`)
|
|
152
|
+
|
|
153
|
+
return result.join('\n')
|
|
154
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command classification helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every technique asks the same question first — "is this the kind of command
|
|
5
|
+
* whose output I know how to summarize?" — so the normalization lives here
|
|
6
|
+
* rather than being repeated (and drifting) per technique.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/
|
|
10
|
+
const CHAIN_OPERATORS = ['&&', '||', ';', '|'] as const
|
|
11
|
+
|
|
12
|
+
function sliceFirstSegment(command: string): string {
|
|
13
|
+
let cutIndex = -1
|
|
14
|
+
for (const operator of CHAIN_OPERATORS) {
|
|
15
|
+
const index = command.indexOf(operator)
|
|
16
|
+
if (index === -1) continue
|
|
17
|
+
if (cutIndex === -1 || index < cutIndex) cutIndex = index
|
|
18
|
+
}
|
|
19
|
+
return cutIndex === -1 ? command : command.slice(0, cutIndex)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reduce a command line to its first simple command, lower-cased.
|
|
24
|
+
*
|
|
25
|
+
* `FOO=1 git status | head` normalizes to `git status`: the assignment run is
|
|
26
|
+
* not the command, and the pipeline is not part of what the first command is.
|
|
27
|
+
* A pattern that matches the whole line would classify every prefixed or
|
|
28
|
+
* piped invocation as unknown and skip compaction entirely.
|
|
29
|
+
*
|
|
30
|
+
* @param command - raw command line, possibly multi-line.
|
|
31
|
+
* @returns the normalized first command, or `null` when there is none.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeCommandForDetection(command: string | undefined | null): string | null {
|
|
34
|
+
if (typeof command !== 'string') return null
|
|
35
|
+
|
|
36
|
+
const firstNonEmptyLine = command
|
|
37
|
+
.split(/\r?\n/)
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.find((line) => line.length > 0)
|
|
40
|
+
if (!firstNonEmptyLine) return null
|
|
41
|
+
|
|
42
|
+
const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, '').trim()
|
|
43
|
+
if (!withoutEnvPrefix) return null
|
|
44
|
+
|
|
45
|
+
const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase()
|
|
46
|
+
return firstSegment || null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the command's first simple command matches any pattern. */
|
|
50
|
+
export function matchesCommandPatterns(command: string | undefined | null, patterns: readonly RegExp[]): boolean {
|
|
51
|
+
const normalized = normalizeCommandForDetection(command)
|
|
52
|
+
if (!normalized) return false
|
|
53
|
+
return patterns.some((pattern) => pattern.test(normalized))
|
|
54
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split and reassemble the harness `bash` result envelope.
|
|
3
|
+
*
|
|
4
|
+
* The bash tool renders a result as the stdout tail, an optional `[stderr]`
|
|
5
|
+
* section, and a run of trailing status markers (`[exit code: N]`,
|
|
6
|
+
* `[timed out after …]`, `[sandbox: …]`, …). Those markers are load-bearing:
|
|
7
|
+
* the model is told to check `[exit code: N]` on every call, and the Web UI
|
|
8
|
+
* parses that same line to draw its exit-status pill.
|
|
9
|
+
*
|
|
10
|
+
* Compaction rewrites the output body, so anything that rebuilds the text
|
|
11
|
+
* wholesale — the git, build, and test summarizers all do — would drop the
|
|
12
|
+
* markers with it. This module is the boundary that keeps that from happening:
|
|
13
|
+
* markers are lifted out before compaction and put back after, in their
|
|
14
|
+
* original order.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Markers the bash tool appends after the output body. */
|
|
18
|
+
const TRAILING_MARKER_PATTERNS = [
|
|
19
|
+
/^\[exit code: -?\d+\]$/,
|
|
20
|
+
/^\[killed by signal: .+\]$/,
|
|
21
|
+
/^\[timed out after \d+ms\]$/,
|
|
22
|
+
/^\[output truncated; full output: .+\]$/,
|
|
23
|
+
/^\[some output was dropped from memory; full output: .+\]$/,
|
|
24
|
+
/^\[sandbox: .+\]$/,
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
const STDERR_HEADER = '[stderr]'
|
|
28
|
+
/** The bash tool's placeholder for a command that printed nothing at all. */
|
|
29
|
+
export const NO_OUTPUT_PLACEHOLDER = '(no output)'
|
|
30
|
+
|
|
31
|
+
function isTrailingMarker(line: string): boolean {
|
|
32
|
+
return TRAILING_MARKER_PATTERNS.some((pattern) => pattern.test(line))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A bash result decomposed into the parts compaction must treat differently. */
|
|
36
|
+
export interface BashResultParts {
|
|
37
|
+
/** True when the tool rendered its "printed nothing" placeholder. */
|
|
38
|
+
empty: boolean
|
|
39
|
+
/** stdout body, without the `[stderr]` section or trailing markers. */
|
|
40
|
+
stdout: string
|
|
41
|
+
/** stderr body when the result carried a `[stderr]` section. */
|
|
42
|
+
stderr?: string
|
|
43
|
+
/** Trailing status markers, in their original order. */
|
|
44
|
+
markers: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decompose a rendered bash result.
|
|
49
|
+
*
|
|
50
|
+
* Trailing markers are peeled from the end first, then the `[stderr]` section
|
|
51
|
+
* is split off. Only the first `[stderr]` header counts: if the command's own
|
|
52
|
+
* output contains that literal line, everything after it belongs to stderr,
|
|
53
|
+
* which is what the renderer produced.
|
|
54
|
+
*
|
|
55
|
+
* @param text - the rendered bash result.
|
|
56
|
+
* @returns the decomposed parts.
|
|
57
|
+
*/
|
|
58
|
+
export function parseBashResult(text: string): BashResultParts {
|
|
59
|
+
if (text === NO_OUTPUT_PLACEHOLDER) {
|
|
60
|
+
return { empty: true, stdout: '', markers: [] }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = text.split('\n')
|
|
64
|
+
const markers: string[] = []
|
|
65
|
+
while (lines.length > 0) {
|
|
66
|
+
const last = lines[lines.length - 1]
|
|
67
|
+
if (last === undefined || !isTrailingMarker(last)) break
|
|
68
|
+
markers.unshift(last)
|
|
69
|
+
lines.pop()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const headerIndex = lines.indexOf(STDERR_HEADER)
|
|
73
|
+
if (headerIndex === -1) {
|
|
74
|
+
return { empty: false, stdout: lines.join('\n'), markers }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stdout = lines.slice(0, headerIndex).join('\n')
|
|
78
|
+
const stderr = lines.slice(headerIndex + 1).join('\n')
|
|
79
|
+
return { empty: false, stdout, stderr, markers }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reassemble a bash result from its parts.
|
|
84
|
+
*
|
|
85
|
+
* The inverse of {@link parseBashResult}, so the model-visible contract — the
|
|
86
|
+
* marker lines the agent loop and the UI both depend on — survives a
|
|
87
|
+
* compaction pass untouched.
|
|
88
|
+
*/
|
|
89
|
+
export function renderBashResult(parts: BashResultParts): string {
|
|
90
|
+
const sections: string[] = []
|
|
91
|
+
if (parts.empty) {
|
|
92
|
+
sections.push(NO_OUTPUT_PLACEHOLDER)
|
|
93
|
+
} else {
|
|
94
|
+
if (parts.stdout.length > 0) sections.push(parts.stdout)
|
|
95
|
+
if (parts.stderr !== undefined) sections.push(STDERR_HEADER, parts.stderr)
|
|
96
|
+
}
|
|
97
|
+
sections.push(...parts.markers)
|
|
98
|
+
return sections.join('\n')
|
|
99
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { matchesCommandPatterns, normalizeCommandForDetection } from './detect.js'
|
|
2
|
+
|
|
3
|
+
const GIT_COMMAND_PATTERNS = [/^git\s+(diff|status|log|show|stash)\b/] as const
|
|
4
|
+
const RAW_GIT_DIFF_PATTERN = /^diff --git /m
|
|
5
|
+
const RAW_GIT_STATUS_PATTERN = /^(?:## |(?:M|A|D|R|C|U|\?| )\S)/m
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One line of `git status --porcelain` output.
|
|
9
|
+
*
|
|
10
|
+
* Covers the v1 branch header (`## main...origin/main`), the v2 headers
|
|
11
|
+
* (`# branch.head main`), the `??` untracked form, and the two-column status
|
|
12
|
+
* form. The human report contains none of these shapes.
|
|
13
|
+
*/
|
|
14
|
+
const PORCELAIN_STATUS_LINE = /^(?:##?\s|\?\? .+|[ MADRCU?!]{2} .+)/
|
|
15
|
+
|
|
16
|
+
/** Share of non-blank lines that must parse as porcelain before summarizing. */
|
|
17
|
+
const PORCELAIN_LINE_MIN_RATIO = 0.8
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a body is `git status --porcelain` rather than the human report.
|
|
21
|
+
*
|
|
22
|
+
* The two are indistinguishable line by line — ` M path` is valid porcelain
|
|
23
|
+
* and also occurs inside the human report — but only porcelain may be sliced
|
|
24
|
+
* with fixed column offsets. Running the summarizer over the human text
|
|
25
|
+
* silently invents and drops entries, so the body must be *consistently*
|
|
26
|
+
* porcelain before any of it is interpreted.
|
|
27
|
+
*/
|
|
28
|
+
function isPorcelainStatus(output: string): boolean {
|
|
29
|
+
const lines = output.split('\n').filter((line) => line.trim().length > 0)
|
|
30
|
+
if (lines.length === 0) return false
|
|
31
|
+
const matches = lines.filter((line) => PORCELAIN_STATUS_LINE.test(line)).length
|
|
32
|
+
return matches / lines.length >= PORCELAIN_LINE_MIN_RATIO
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether the command is one of the git commands this module understands. */
|
|
36
|
+
export function isGitCommand(command: string | undefined | null): boolean {
|
|
37
|
+
return matchesCommandPatterns(command, GIT_COMMAND_PATTERNS)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Condense a unified diff to file headers, hunk headers, and a bounded sample
|
|
42
|
+
* of changed lines, with an added/removed count per file.
|
|
43
|
+
*/
|
|
44
|
+
export function compactDiff(output: string, maxLines = 50): string {
|
|
45
|
+
const lines = output.split('\n')
|
|
46
|
+
const result: string[] = []
|
|
47
|
+
let currentFile = ''
|
|
48
|
+
let added = 0
|
|
49
|
+
let removed = 0
|
|
50
|
+
let inHunk = false
|
|
51
|
+
let hunkLines = 0
|
|
52
|
+
const maxHunkLines = 10
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (result.length >= maxLines) {
|
|
56
|
+
result.push('\n... (more changes truncated)')
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (line.startsWith('diff --git')) {
|
|
61
|
+
if (currentFile && (added > 0 || removed > 0)) result.push(` +${added} -${removed}`)
|
|
62
|
+
const match = line.match(/diff --git a\/(.+) b\/(.+)/)
|
|
63
|
+
currentFile = match?.[2] ?? 'unknown'
|
|
64
|
+
result.push(`\n> ${currentFile}`)
|
|
65
|
+
added = 0
|
|
66
|
+
removed = 0
|
|
67
|
+
inHunk = false
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (line.startsWith('@@')) {
|
|
72
|
+
inHunk = true
|
|
73
|
+
hunkLines = 0
|
|
74
|
+
result.push(` ${line.match(/@@ .+ @@/)?.[0] ?? '@@'}`)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!inHunk) continue
|
|
79
|
+
|
|
80
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
81
|
+
added++
|
|
82
|
+
if (hunkLines < maxHunkLines) {
|
|
83
|
+
result.push(` ${line}`)
|
|
84
|
+
hunkLines++
|
|
85
|
+
}
|
|
86
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
87
|
+
removed++
|
|
88
|
+
if (hunkLines < maxHunkLines) {
|
|
89
|
+
result.push(` ${line}`)
|
|
90
|
+
hunkLines++
|
|
91
|
+
}
|
|
92
|
+
} else if (hunkLines < maxHunkLines && !line.startsWith('\\')) {
|
|
93
|
+
if (hunkLines > 0) {
|
|
94
|
+
result.push(` ${line}`)
|
|
95
|
+
hunkLines++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (hunkLines === maxHunkLines) {
|
|
100
|
+
result.push(' ... (truncated)')
|
|
101
|
+
hunkLines++
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (currentFile && (added > 0 || removed > 0)) result.push(` +${added} -${removed}`)
|
|
106
|
+
return result.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface StatusStats {
|
|
110
|
+
staged: number
|
|
111
|
+
modified: number
|
|
112
|
+
untracked: number
|
|
113
|
+
conflicts: number
|
|
114
|
+
stagedFiles: string[]
|
|
115
|
+
modifiedFiles: string[]
|
|
116
|
+
untrackedFiles: string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Condense `git status` porcelain output into per-bucket counts and samples. */
|
|
120
|
+
export function compactStatus(output: string): string {
|
|
121
|
+
const lines = output.split('\n')
|
|
122
|
+
if (lines.length === 0 || (lines.length === 1 && lines[0]?.trim() === '')) return 'Clean working tree'
|
|
123
|
+
|
|
124
|
+
const stats: StatusStats = {
|
|
125
|
+
staged: 0,
|
|
126
|
+
modified: 0,
|
|
127
|
+
untracked: 0,
|
|
128
|
+
conflicts: 0,
|
|
129
|
+
stagedFiles: [],
|
|
130
|
+
modifiedFiles: [],
|
|
131
|
+
untrackedFiles: [],
|
|
132
|
+
}
|
|
133
|
+
let branchName = ''
|
|
134
|
+
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
if (line.startsWith('##')) {
|
|
137
|
+
const match = line.match(/## (.+)/)
|
|
138
|
+
if (match?.[1]) branchName = match[1].split('...')[0] ?? match[1]
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (line.length < 3) continue
|
|
142
|
+
|
|
143
|
+
const status = line.slice(0, 2)
|
|
144
|
+
const filename = line.slice(3)
|
|
145
|
+
const indexStatus = status[0]
|
|
146
|
+
const worktreeStatus = status[1]
|
|
147
|
+
|
|
148
|
+
if (indexStatus !== undefined && ['M', 'A', 'D', 'R', 'C'].includes(indexStatus)) {
|
|
149
|
+
stats.staged++
|
|
150
|
+
stats.stagedFiles.push(filename)
|
|
151
|
+
}
|
|
152
|
+
if (indexStatus === 'U') stats.conflicts++
|
|
153
|
+
if (worktreeStatus !== undefined && ['M', 'D'].includes(worktreeStatus)) {
|
|
154
|
+
stats.modified++
|
|
155
|
+
stats.modifiedFiles.push(filename)
|
|
156
|
+
}
|
|
157
|
+
if (status === '??') {
|
|
158
|
+
stats.untracked++
|
|
159
|
+
stats.untrackedFiles.push(filename)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let result = `Branch: ${branchName}\n`
|
|
164
|
+
const append = (label: string, count: number, files: string[], shown: number): void => {
|
|
165
|
+
if (count === 0) return
|
|
166
|
+
result += `${label}: ${count} files\n`
|
|
167
|
+
for (const file of files.slice(0, shown)) result += ` ${file}\n`
|
|
168
|
+
if (count > shown) result += ` ... +${count - shown} more\n`
|
|
169
|
+
}
|
|
170
|
+
append('Staged', stats.staged, stats.stagedFiles, 5)
|
|
171
|
+
append('Modified', stats.modified, stats.modifiedFiles, 5)
|
|
172
|
+
append('Untracked', stats.untracked, stats.untrackedFiles, 3)
|
|
173
|
+
if (stats.conflicts > 0) result += `Conflicts: ${stats.conflicts} files\n`
|
|
174
|
+
|
|
175
|
+
return result.trim()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Keep the first `limit` log lines, capping each line's width. */
|
|
179
|
+
export function compactLog(output: string, limit = 20): string {
|
|
180
|
+
const lines = output.split('\n')
|
|
181
|
+
const result: string[] = []
|
|
182
|
+
for (const line of lines.slice(0, limit)) {
|
|
183
|
+
result.push(line.length > 80 ? `${line.slice(0, 77)}...` : line)
|
|
184
|
+
}
|
|
185
|
+
if (lines.length > limit) result.push(`... and ${lines.length - limit} more commits`)
|
|
186
|
+
return result.join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compact a git command's output, or `null` when nothing applies.
|
|
191
|
+
*
|
|
192
|
+
* Each branch first checks that the output actually looks like raw git output.
|
|
193
|
+
* A command like `git diff --stat` inside a wrapper, or a script whose name
|
|
194
|
+
* merely starts with `git`, would otherwise be rewritten into a summary of
|
|
195
|
+
* text this module never parsed correctly.
|
|
196
|
+
*/
|
|
197
|
+
export function compactGitOutput(output: string, command: string | undefined | null): string | null {
|
|
198
|
+
if (!isGitCommand(command)) return null
|
|
199
|
+
const normalized = normalizeCommandForDetection(command)
|
|
200
|
+
if (!normalized) return null
|
|
201
|
+
|
|
202
|
+
if (normalized.startsWith('git diff')) return RAW_GIT_DIFF_PATTERN.test(output) ? compactDiff(output) : null
|
|
203
|
+
if (normalized.startsWith('git status')) {
|
|
204
|
+
if (!isPorcelainStatus(output)) return null
|
|
205
|
+
return RAW_GIT_STATUS_PATTERN.test(output) ? compactStatus(output) : null
|
|
206
|
+
}
|
|
207
|
+
if (normalized.startsWith('git log')) return compactLog(output)
|
|
208
|
+
return null
|
|
209
|
+
}
|