From 1323443c85e3eaa43aade5719dbb4f7e561195e4 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sat, 13 Dec 2025 14:23:04 +0900 Subject: [PATCH] refactor: extract shared utilities (`isMarkdownFile`, `isPlainObject`, `resolveSymlink`) (#33) --- .../claude-code-agent-loader/loader.ts | 5 +--- .../claude-code-command-loader/loader.ts | 5 +--- .../claude-code-skill-loader/loader.ts | 14 +++------- src/shared/deep-merge.ts | 2 +- src/shared/file-utils.ts | 26 ++++++++++++++++++ src/shared/index.ts | 1 + src/shared/snake-case.ts | 6 ++--- src/tools/skill/tools.ts | 27 +++---------------- src/tools/slashcommand/tools.ts | 5 ++-- 9 files changed, 42 insertions(+), 49 deletions(-) create mode 100644 src/shared/file-utils.ts diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 27425d5..0bd26ba 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -3,6 +3,7 @@ import { homedir } from "os" import { join, basename } from "path" import type { AgentConfig } from "@opencode-ai/sdk" import { parseFrontmatter } from "../../shared/frontmatter" +import { isMarkdownFile } from "../../shared/file-utils" import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types" function parseToolsConfig(toolsStr?: string): Record | undefined { @@ -18,10 +19,6 @@ function parseToolsConfig(toolsStr?: string): Record | undefine return result } -function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { - return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() -} - function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { if (!existsSync(agentsDir)) { return [] diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 97e3f81..f981be8 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -3,12 +3,9 @@ import { homedir } from "os" import { join, basename } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" +import { isMarkdownFile } from "../../shared/file-utils" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" -function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { - return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() -} - function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] { if (!existsSync(commandsDir)) { return [] diff --git a/src/features/claude-code-skill-loader/loader.ts b/src/features/claude-code-skill-loader/loader.ts index 6de225f..c2a8ec1 100644 --- a/src/features/claude-code-skill-loader/loader.ts +++ b/src/features/claude-code-skill-loader/loader.ts @@ -1,8 +1,9 @@ -import { existsSync, readdirSync, readFileSync, lstatSync, readlinkSync } from "fs" +import { existsSync, readdirSync, readFileSync } from "fs" import { homedir } from "os" -import { join, resolve } from "path" +import { join } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" +import { resolveSymlink } from "../../shared/file-utils" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types" @@ -21,14 +22,7 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsC if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - let resolvedPath = skillPath - try { - if (lstatSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) { - resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath)) - } - } catch { - continue - } + const resolvedPath = resolveSymlink(skillPath) const skillMdPath = join(resolvedPath, "SKILL.md") if (!existsSync(skillMdPath)) continue diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts index 1f7a79b..9256782 100644 --- a/src/shared/deep-merge.ts +++ b/src/shared/deep-merge.ts @@ -1,7 +1,7 @@ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); const MAX_DEPTH = 50; -function isPlainObject(value: unknown): value is Record { +export function isPlainObject(value: unknown): value is Record { return ( typeof value === "object" && value !== null && diff --git a/src/shared/file-utils.ts b/src/shared/file-utils.ts new file mode 100644 index 0000000..d55bd05 --- /dev/null +++ b/src/shared/file-utils.ts @@ -0,0 +1,26 @@ +import { lstatSync, readlinkSync } from "fs" +import { resolve } from "path" + +export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { + return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() +} + +export function isSymbolicLink(filePath: string): boolean { + try { + return lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false + } catch { + return false + } +} + +export function resolveSymlink(filePath: string): string { + try { + const stats = lstatSync(filePath, { throwIfNoEntry: false }) + if (stats?.isSymbolicLink()) { + return resolve(filePath, "..", readlinkSync(filePath)) + } + return filePath + } catch { + return filePath + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index d96c880..3d7b58c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -8,3 +8,4 @@ export * from "./tool-name" export * from "./pattern-matcher" export * from "./hook-disabled" export * from "./deep-merge" +export * from "./file-utils" diff --git a/src/shared/snake-case.ts b/src/shared/snake-case.ts index cec7278..cb24707 100644 --- a/src/shared/snake-case.ts +++ b/src/shared/snake-case.ts @@ -1,3 +1,5 @@ +import { isPlainObject } from "./deep-merge" + export function camelToSnake(str: string): string { return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) } @@ -6,10 +8,6 @@ export function snakeToCamel(str: string): string { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - export function objectToSnakeCase( obj: Record, deep: boolean = true diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index bcef908..452b0b5 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -1,9 +1,10 @@ import { tool } from "@opencode-ai/plugin" -import { existsSync, readdirSync, lstatSync, readlinkSync, readFileSync } from "fs" +import { existsSync, readdirSync, readFileSync } from "fs" import { homedir } from "os" -import { join, resolve, basename } from "path" +import { join, basename } from "path" import { z } from "zod/v4" import { parseFrontmatter, resolveCommandsInText } from "../../shared" +import { resolveSymlink } from "../../shared/file-utils" import { SkillFrontmatterSchema } from "./types" import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill, SkillFrontmatter } from "./types" @@ -37,15 +38,7 @@ function discoverSkillsFromDir( const skillPath = join(skillsDir, entry.name) if (entry.isDirectory() || entry.isSymbolicLink()) { - let resolvedPath = skillPath - try { - const stats = lstatSync(skillPath, { throwIfNoEntry: false }) - if (stats?.isSymbolicLink()) { - resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath)) - } - } catch { - continue - } + const resolvedPath = resolveSymlink(skillPath) const skillMdPath = join(resolvedPath, "SKILL.md") if (!existsSync(skillMdPath)) continue @@ -83,18 +76,6 @@ const skillListForDescription = availableSkills .map((s) => `- ${s.name}: ${s.description} (${s.scope})`) .join("\n") -function resolveSymlink(skillPath: string): string { - try { - const stats = lstatSync(skillPath, { throwIfNoEntry: false }) - if (stats?.isSymbolicLink()) { - return resolve(skillPath, "..", readlinkSync(skillPath)) - } - return skillPath - } catch { - return skillPath - } -} - async function parseSkillMd(skillPath: string): Promise { const resolvedPath = resolveSymlink(skillPath) const skillMdPath = join(resolvedPath, "SKILL.md") diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 21f9544..e03a66a 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { homedir } from "os" import { join, basename, dirname } from "path" import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" +import { isMarkdownFile } from "../../shared/file-utils" import type { CommandScope, CommandMetadata, CommandInfo } from "./types" function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { @@ -14,9 +15,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm const commands: CommandInfo[] = [] for (const entry of entries) { - if (entry.name.startsWith(".")) continue - if (!entry.name.endsWith(".md")) continue - if (!entry.isFile()) continue + if (!isMarkdownFile(entry)) continue const commandPath = join(commandsDir, entry.name) const commandName = basename(entry.name, ".md")