feat(shared): add shared utilities for command and skill loading
- frontmatter.ts: YAML frontmatter parser - file-reference-resolver.ts: resolve @file references in markdown - command-executor.ts: execute shell commands in markdown - model-sanitizer.ts: sanitize model names for OpenCode compatibility 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
203
src/shared/command-executor.ts
Normal file
203
src/shared/command-executor.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import { exec } from "child_process"
|
||||||
|
import { promisify } from "util"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
|
||||||
|
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||||
|
|
||||||
|
function findZshPath(customZshPath?: string): string | null {
|
||||||
|
if (customZshPath && existsSync(customZshPath)) {
|
||||||
|
return customZshPath
|
||||||
|
}
|
||||||
|
for (const path of DEFAULT_ZSH_PATHS) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
export interface CommandResult {
|
||||||
|
exitCode: number
|
||||||
|
stdout?: string
|
||||||
|
stderr?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteHookOptions {
|
||||||
|
forceZsh?: boolean
|
||||||
|
zshPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a hook command with stdin input
|
||||||
|
*/
|
||||||
|
export async function executeHookCommand(
|
||||||
|
command: string,
|
||||||
|
stdin: string,
|
||||||
|
cwd: string,
|
||||||
|
options?: ExecuteHookOptions
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
const home = process.env.HOME ?? ""
|
||||||
|
|
||||||
|
let expandedCommand = command
|
||||||
|
.replace(/^~(?=\/|$)/g, home)
|
||||||
|
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||||
|
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||||
|
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
|
||||||
|
|
||||||
|
let finalCommand = expandedCommand
|
||||||
|
|
||||||
|
if (options?.forceZsh) {
|
||||||
|
const zshPath = options.zshPath || findZshPath()
|
||||||
|
if (zshPath) {
|
||||||
|
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||||
|
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn(finalCommand, {
|
||||||
|
cwd,
|
||||||
|
shell: true,
|
||||||
|
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ""
|
||||||
|
let stderr = ""
|
||||||
|
|
||||||
|
proc.stdout?.on("data", (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdin?.write(stdin)
|
||||||
|
proc.stdin?.end()
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: code ?? 0,
|
||||||
|
stdout: stdout.trim(),
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: 1,
|
||||||
|
stderr: err.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a simple command and return output
|
||||||
|
*/
|
||||||
|
export async function executeCommand(command: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(command)
|
||||||
|
|
||||||
|
const out = stdout?.toString().trim() ?? ""
|
||||||
|
const err = stderr?.toString().trim() ?? ""
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (out) {
|
||||||
|
return `${out}\n[stderr: ${err}]`
|
||||||
|
}
|
||||||
|
return `[stderr: ${err}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string }
|
||||||
|
const stdout = e?.stdout?.toString().trim() ?? ""
|
||||||
|
const stderr = e?.stderr?.toString().trim() ?? ""
|
||||||
|
const errMsg = stderr || e?.message || String(error)
|
||||||
|
|
||||||
|
if (stdout) {
|
||||||
|
return `${stdout}\n[stderr: ${errMsg}]`
|
||||||
|
}
|
||||||
|
return `[stderr: ${errMsg}]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and execute embedded commands in text (!`command`)
|
||||||
|
*/
|
||||||
|
interface CommandMatch {
|
||||||
|
fullMatch: string
|
||||||
|
command: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_PATTERN = /!`([^`]+)`/g
|
||||||
|
|
||||||
|
function findCommands(text: string): CommandMatch[] {
|
||||||
|
const matches: CommandMatch[] = []
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
COMMAND_PATTERN.lastIndex = 0
|
||||||
|
|
||||||
|
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
fullMatch: match[0],
|
||||||
|
command: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve embedded commands in text recursively
|
||||||
|
*/
|
||||||
|
export async function resolveCommandsInText(
|
||||||
|
text: string,
|
||||||
|
depth: number = 0,
|
||||||
|
maxDepth: number = 3
|
||||||
|
): Promise<string> {
|
||||||
|
if (depth >= maxDepth) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = findCommands(text)
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = matches.map((m) => executeCommand(m.command))
|
||||||
|
const results = await Promise.allSettled(tasks)
|
||||||
|
|
||||||
|
const replacements = new Map<string, string>()
|
||||||
|
|
||||||
|
matches.forEach((match, idx) => {
|
||||||
|
const result = results[idx]
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
replacements.set(
|
||||||
|
match.fullMatch,
|
||||||
|
`[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
replacements.set(match.fullMatch, result.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let resolved = text
|
||||||
|
for (const [pattern, replacement] of replacements.entries()) {
|
||||||
|
resolved = resolved.split(pattern).join(replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findCommands(resolved).length > 0) {
|
||||||
|
return resolveCommandsInText(resolved, depth + 1, maxDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
85
src/shared/file-reference-resolver.ts
Normal file
85
src/shared/file-reference-resolver.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from "fs"
|
||||||
|
import { join, isAbsolute } from "path"
|
||||||
|
|
||||||
|
interface FileMatch {
|
||||||
|
fullMatch: string
|
||||||
|
filePath: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_REFERENCE_PATTERN = /@([^\s@]+)/g
|
||||||
|
|
||||||
|
function findFileReferences(text: string): FileMatch[] {
|
||||||
|
const matches: FileMatch[] = []
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
FILE_REFERENCE_PATTERN.lastIndex = 0
|
||||||
|
|
||||||
|
while ((match = FILE_REFERENCE_PATTERN.exec(text)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
fullMatch: match[0],
|
||||||
|
filePath: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilePath(filePath: string, cwd: string): string {
|
||||||
|
if (isAbsolute(filePath)) {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
return join(cwd, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileContent(resolvedPath: string): string {
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
return `[file not found: ${resolvedPath}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = statSync(resolvedPath)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return `[cannot read directory: ${resolvedPath}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(resolvedPath, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFileReferencesInText(
|
||||||
|
text: string,
|
||||||
|
cwd: string = process.cwd(),
|
||||||
|
depth: number = 0,
|
||||||
|
maxDepth: number = 3
|
||||||
|
): Promise<string> {
|
||||||
|
if (depth >= maxDepth) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = findFileReferences(text)
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacements = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const resolvedPath = resolveFilePath(match.filePath, cwd)
|
||||||
|
const content = readFileContent(resolvedPath)
|
||||||
|
replacements.set(match.fullMatch, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = text
|
||||||
|
for (const [pattern, replacement] of replacements.entries()) {
|
||||||
|
resolved = resolved.split(pattern).join(replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
|
||||||
|
return resolveFileReferencesInText(resolved, cwd, depth + 1, maxDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
34
src/shared/frontmatter.ts
Normal file
34
src/shared/frontmatter.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export interface FrontmatterResult<T = Record<string, string>> {
|
||||||
|
data: T
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFrontmatter<T = Record<string, string>>(
|
||||||
|
content: string
|
||||||
|
): FrontmatterResult<T> {
|
||||||
|
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||||
|
const match = content.match(frontmatterRegex)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { data: {} as T, body: content }
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlContent = match[1]
|
||||||
|
const body = match[2]
|
||||||
|
|
||||||
|
const data: Record<string, string | boolean> = {}
|
||||||
|
for (const line of yamlContent.split("\n")) {
|
||||||
|
const colonIndex = line.indexOf(":")
|
||||||
|
if (colonIndex !== -1) {
|
||||||
|
const key = line.slice(0, colonIndex).trim()
|
||||||
|
let value: string | boolean = line.slice(colonIndex + 1).trim()
|
||||||
|
|
||||||
|
if (value === "true") value = true
|
||||||
|
else if (value === "false") value = false
|
||||||
|
|
||||||
|
data[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: data as T, body }
|
||||||
|
}
|
||||||
4
src/shared/index.ts
Normal file
4
src/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./frontmatter"
|
||||||
|
export * from "./command-executor"
|
||||||
|
export * from "./file-reference-resolver"
|
||||||
|
export * from "./model-sanitizer"
|
||||||
13
src/shared/model-sanitizer.ts
Normal file
13
src/shared/model-sanitizer.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Sanitizes model field from frontmatter.
|
||||||
|
* Always returns undefined to let SDK use default model.
|
||||||
|
*
|
||||||
|
* Claude Code and OpenCode use different model ID formats,
|
||||||
|
* so we ignore the model field and let OpenCode use its configured default.
|
||||||
|
*
|
||||||
|
* @param _model - Raw model value from frontmatter (ignored)
|
||||||
|
* @returns Always undefined to inherit default model
|
||||||
|
*/
|
||||||
|
export function sanitizeModelField(_model: unknown): undefined {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user