From 47f218e33fa503dfb47cc0d02d82e59f77856bb2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 9 Dec 2025 15:48:05 +0900 Subject: [PATCH] feat(shared): add shared utilities for command and skill loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/shared/command-executor.ts | 203 ++++++++++++++++++++++++++ src/shared/file-reference-resolver.ts | 85 +++++++++++ src/shared/frontmatter.ts | 34 +++++ src/shared/index.ts | 4 + src/shared/model-sanitizer.ts | 13 ++ 5 files changed, 339 insertions(+) create mode 100644 src/shared/command-executor.ts create mode 100644 src/shared/file-reference-resolver.ts create mode 100644 src/shared/frontmatter.ts create mode 100644 src/shared/index.ts create mode 100644 src/shared/model-sanitizer.ts diff --git a/src/shared/command-executor.ts b/src/shared/command-executor.ts new file mode 100644 index 0000000..060eace --- /dev/null +++ b/src/shared/command-executor.ts @@ -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 { + 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 { + 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 { + 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() + + 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 +} diff --git a/src/shared/file-reference-resolver.ts b/src/shared/file-reference-resolver.ts new file mode 100644 index 0000000..20b2ed9 --- /dev/null +++ b/src/shared/file-reference-resolver.ts @@ -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 { + if (depth >= maxDepth) { + return text + } + + const matches = findFileReferences(text) + if (matches.length === 0) { + return text + } + + const replacements = new Map() + + 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 +} diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts new file mode 100644 index 0000000..f0bfbbe --- /dev/null +++ b/src/shared/frontmatter.ts @@ -0,0 +1,34 @@ +export interface FrontmatterResult> { + data: T + body: string +} + +export function parseFrontmatter>( + content: string +): FrontmatterResult { + 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 = {} + 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 } +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..c6f7ca8 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./frontmatter" +export * from "./command-executor" +export * from "./file-reference-resolver" +export * from "./model-sanitizer" diff --git a/src/shared/model-sanitizer.ts b/src/shared/model-sanitizer.ts new file mode 100644 index 0000000..adb8cf6 --- /dev/null +++ b/src/shared/model-sanitizer.ts @@ -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 +}