mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
// Why: byte-preserving edits of jcode's `~/.jcode/config.toml` [hooks] table,
|
|
// following the codebase convention of hand-rolled TOML scanning (no TOML
|
|
// dependency, see src/main/codex/config-toml-line-scan.ts). jcode hook values
|
|
// are scalar commands; user-owned values are left untouched so Orca never
|
|
// silently disables a user's own hook.
|
|
import {
|
|
createTomlLineScanState,
|
|
getTomlTableHeader,
|
|
isTomlStructuralLine,
|
|
updateTomlLineScanState,
|
|
parseTomlSingleLineStringValue
|
|
} from '../codex/config-toml-line-scan'
|
|
import { parseTomlKeyPath } from '../codex/config-toml-key-path'
|
|
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
|
|
|
export type JcodeHooksTable = Record<string, string>
|
|
|
|
export function parseJcodeHooksTable(content: string): JcodeHooksTable | null {
|
|
const hooks: JcodeHooksTable = {}
|
|
let state = createTomlLineScanState()
|
|
let inHooksTable = false
|
|
let parseError = false
|
|
for (const line of content.split('\n')) {
|
|
if (!isTomlStructuralLine(state)) {
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
const header = getTomlTableHeader(line)
|
|
if (header) {
|
|
inHooksTable = parseTomlTablePath(header)?.join('.') === 'hooks'
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
if (inHooksTable) {
|
|
const parsed = parseTomlKeyPath(line)
|
|
if (parsed && line[parsed.end] === '=') {
|
|
const value = parseTomlSingleLineStringValue(line, parsed.end + 1)
|
|
if (value) {
|
|
hooks[parsed.segments[0]] = value.value
|
|
} else if (
|
|
parsed.segments.length === 1 &&
|
|
line.trim().length > 0 &&
|
|
!isTomlScalarValue(line, parsed.end + 1)
|
|
) {
|
|
// Why: a multiline string hook value is not a scalar command; treat
|
|
// the whole table as unmanaged so install reports `error` instead of
|
|
// clobbering it. Scalar non-string values (jcode's own
|
|
// `pre_tool_timeout_ms = 5000`) are legitimate config, just not
|
|
// commands, so they are skipped.
|
|
parseError = true
|
|
}
|
|
}
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
}
|
|
if (parseError) {
|
|
return null
|
|
}
|
|
return hooks
|
|
}
|
|
|
|
const TOML_SCALAR_VALUE_RE = /^\s*(?:true|false|[-+]?\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?)\s*$/
|
|
|
|
function isTomlScalarValue(line: string, offset: number): boolean {
|
|
return TOML_SCALAR_VALUE_RE.test(line.slice(offset))
|
|
}
|
|
|
|
function parseTomlTablePath(header: string): string[] | null {
|
|
const trimmed = header.trim().replace(/^\[+|\]+$/g, '')
|
|
if (trimmed.length === 0) {
|
|
return null
|
|
}
|
|
return trimmed.split('.').map((segment) => segment.trim().replace(/^"|"$/g, ''))
|
|
}
|
|
|
|
export function tomlQuoteString(value: string): string {
|
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
|
|
}
|
|
|
|
export type JcodeHooksEdit = {
|
|
content: string
|
|
/** Managed keys whose value is a user-owned command (kept, reported partial). */
|
|
userOwnedEvents: readonly string[]
|
|
}
|
|
|
|
export function applyJcodeManagedHooks(
|
|
content: string,
|
|
events: readonly string[],
|
|
managedCommand: string,
|
|
scriptFileName: string
|
|
): JcodeHooksEdit {
|
|
const isManaged = createManagedCommandMatcher(scriptFileName)
|
|
// Why: a CRLF config leaves a trailing `\r` on each split line; re-joining
|
|
// with the CRLF eol would double it into `\r\r\n` and grow the file per edit.
|
|
const lines = content.split('\n').map((line) => line.replace(/\r$/, ''))
|
|
const usesCrlf = content.includes('\r\n')
|
|
const eol = usesCrlf ? '\r\n' : '\n'
|
|
let state = createTomlLineScanState()
|
|
let inHooksTable = false
|
|
let hooksHeaderIndex = -1
|
|
const existingKeyIndexes = new Map<string, number>()
|
|
const userOwnedEvents: string[] = []
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? ''
|
|
if (!isTomlStructuralLine(state)) {
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
const header = getTomlTableHeader(line)
|
|
if (header) {
|
|
if (inHooksTable) {
|
|
break
|
|
}
|
|
if (parseTomlTablePath(header)?.join('.') === 'hooks') {
|
|
inHooksTable = true
|
|
hooksHeaderIndex = index
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
if (inHooksTable) {
|
|
const parsed = parseTomlKeyPath(line)
|
|
if (parsed && line[parsed.end] === '=' && parsed.segments.length === 1) {
|
|
const key = parsed.segments[0]
|
|
if (events.includes(key)) {
|
|
if (isManaged(line)) {
|
|
existingKeyIndexes.set(key, index)
|
|
} else {
|
|
userOwnedEvents.push(key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
}
|
|
|
|
const missing = events.filter(
|
|
(event) => !existingKeyIndexes.has(event) && !userOwnedEvents.includes(event)
|
|
)
|
|
const insertions: string[] = []
|
|
for (const event of missing) {
|
|
insertions.push(`${event} = ${tomlQuoteString(managedCommand)}`)
|
|
}
|
|
let result = content
|
|
if (insertions.length > 0) {
|
|
if (hooksHeaderIndex === -1) {
|
|
// Why: append a new [hooks] table at the end; jcode re-reads config on
|
|
// reload, so placement at EOF is safe.
|
|
result = `${result.endsWith('\n') || result.length === 0 ? result : `${result}\n`}[hooks]${eol}${insertions.join(eol)}${eol}`
|
|
} else {
|
|
const insertionLines = [...lines]
|
|
insertionLines.splice(hooksHeaderIndex + 1, 0, ...insertions)
|
|
result = insertionLines.join(eol)
|
|
}
|
|
}
|
|
return { content: result, userOwnedEvents }
|
|
}
|
|
|
|
export function removeJcodeManagedHooks(
|
|
content: string,
|
|
scriptFileName: string
|
|
): { content: string; changed: boolean } {
|
|
const isManaged = createManagedCommandMatcher(scriptFileName)
|
|
// Why: same CRLF strip as applyJcodeManagedHooks so re-joining never doubles `\r`.
|
|
const lines = content.split('\n').map((line) => line.replace(/\r$/, ''))
|
|
const usesCrlf = content.includes('\r\n')
|
|
const eol = usesCrlf ? '\r\n' : '\n'
|
|
let state = createTomlLineScanState()
|
|
let inHooksTable = false
|
|
let changed = false
|
|
const kept: string[] = []
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index] ?? ''
|
|
if (!isTomlStructuralLine(state)) {
|
|
kept.push(line)
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
const header = getTomlTableHeader(line)
|
|
if (header) {
|
|
// Why: leaving the table stops the removal, but the rest of the file must
|
|
// still be copied out — `kept` is the whole result, so breaking here once
|
|
// truncated every table declared after [hooks].
|
|
inHooksTable = parseTomlTablePath(header)?.join('.') === 'hooks'
|
|
kept.push(line)
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
if (inHooksTable) {
|
|
const parsed = parseTomlKeyPath(line)
|
|
if (parsed && line[parsed.end] === '=' && parsed.segments.length === 1 && isManaged(line)) {
|
|
changed = true
|
|
state = updateTomlLineScanState(state, line)
|
|
continue
|
|
}
|
|
}
|
|
kept.push(line)
|
|
state = updateTomlLineScanState(state, line)
|
|
}
|
|
if (!changed) {
|
|
return { content, changed: false }
|
|
}
|
|
return { content: kept.join(eol), changed: true }
|
|
}
|