import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "fs"; import { basename, dirname, relative, resolve, sep, win32, posix } from "path"; import { homedir } from "os"; import { pathToFileURL, fileURLToPath } from "url"; import { createInterface } from "readline"; import { createHash } from "crypto"; import YAML from "yaml"; // Loose options bag — all CLI functions accept this so callers don't need to list every key. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type CliOpts = Record; // #622: cli-ticket-home.mjs registers itself here at import time to avoid a static // circular import (home imports utils). detectConsumerTicketDir uses it when present. let _ticketHomeModule = null; export function registerTicketHomeModule(mod) { _ticketHomeModule = mod; } function ticketHomeModule() { if (!_ticketHomeModule) throw new Error("ticket home module not registered"); return _ticketHomeModule; } /** Converts an absolute path to a clickable file:/// URI */ export function toFileUri(absPath) { return pathToFileURL(absPath).href; } /** Converts an absolute path to a vscode://file/ URI (clickable in VSCode terminal) */ export function toVscodeFileUri(absPath) { const posixPath = absPath.replace(/\\/g, "/"); const normalized = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; return `vscode://file${normalized}`; } /** writeFileSync wrapper that always normalizes to LF before writing */ export function writeFileLF(path: string, content: string, encoding: BufferEncoding = "utf8") { const normalized = String(content).replace(/\r\n/g, "\n"); writeFileSync(path, normalized, encoding); } // #713: The deuk home root fragment (the directory name under the user home / workspace). // Holds deuk-family-wide assets (tickets/registry/...). VALUE switched .deuk-agent→.deuk // (#710 renamed the symbol AGENT_ROOT_DIR→DEUK_ROOT_DIR; #713 flips the value). The legacy // ~/.deuk-agent directory is migrated to ~/.deuk by init auto-migration (cli-init-migrate). // The AGENT_ROOT_DIR alias is removed — all .ts call sites use DEUK_ROOT_DIR. export const DEUK_ROOT_DIR = ".deuk"; export const WORKSPACE_MARKER_FILE = ".deuk-workspace-id"; export const TICKET_SUBDIR = "tickets"; export const RULES_SUBDIR = "rules"; export const WORKFLOW_MODE_PLAN = "plan"; export const WORKFLOW_MODE_EXECUTE = "execute"; // #710: relative path under the deuk root. The SINGLE place that joins DEUK_ROOT_DIR with // sub-segments — callers pass fragments only (TICKET_SUBDIR, ...) and never re-assemble the // root themselves. Replaces the assembled constants (TICKET_DIR_NAME/PLAN_LINKS_DIR/...) that // baked the root into SSOT and forced double-assembly across call sites (708 design). export function deukRel(...segments: string[]): string { return makePath(DEUK_ROOT_DIR, ...segments); } export const TICKET_INDEX_FILENAME = "INDEX.json"; export const TICKET_LIST_FILENAME = "TICKET_LIST.md"; export const DOCS_SUBDIR = "docs"; export const PLANS_SUBDIR = "plan"; // #713: SINGLE source of truth for everything about the OLD (.deuk-agent-era) layout. // All legacy-cleanup / scan-exclude / migration code reads from this one object instead of // scattered consts — add a future legacy name here once and every site follows. These are the // OLD names (fixed); they must NEVER follow DEUK_ROOT_DIR (the current ".deuk"), or cleanup // code would start treating the new dir as legacy and delete it. `dirs` = old directory names // that purge/scan/ignore logic must recognize (most-specific first). export const LEGACY_DEUK = { rootDir: ".deuk-agent", templateDir: ".deuk-agent-templates", ticketDir: ".deuk-agent-ticket", ticketDirPlural: ".deuk-agent-tickets", configFile: ".deuk-agent-rule.config.json", ticketGroupRoot: "ticket", dirs: [ ".deuk-agent-templates", ".deuk-agent-tickets", ".deuk-agent-ticket", ".deuk-agent", ], } as const; export const ARCHIVE_YEAR_MONTH_RE = /^\d{4}-\d{2}$/; export const ARCHIVE_DAY_RE = /^\d{2}$/; const LEGACY_TICKET_GROUPS = new Set([ LEGACY_DEUK.ticketDir, LEGACY_DEUK.ticketDirPlural, "ticket", "tickets" ]); /** * Computes the canonical repository-relative path for a ticket based on its state. */ export function computeTicketPath(entry) { // #080: paths are relative to the TICKET ROOT (the home store key dir, // ~/.deuk-agent/tickets/{key}/). The legacy `.deuk-agent/tickets/` prefix dates // from in-workspace storage; keeping it nested every self-heal/create one level // deeper inside the store ({key}/.deuk-agent/tickets/main/...). const isArchived = entry.status === "archived"; const group = normalizeTicketGroup(entry.group, "sub"); const fileStem = entry.fileName ? String(entry.fileName).replace(/\.md$/i, "") : entry.id; if (!isArchived && group === TICKET_SUBDIR) { return `${fileStem}.md`; } if (isArchived && entry.archiveYearMonth) { return ["archive", group, entry.archiveYearMonth, `${fileStem}.md`].join("/"); } const parts = [ isArchived ? "archive" : null, group, `${fileStem}.md` ].filter(Boolean); return parts.join("/"); } export function normalizeTicketGroup(rawGroup, fallback = "sub") { const value = String(rawGroup || "").trim(); if (!value) return fallback; if (value.includes("/") || value.startsWith("TICKET-") && value.includes(".md")) return fallback; if (value.endsWith(".md")) return fallback; if (value.endsWith(".markdown")) return fallback; if (LEGACY_TICKET_GROUPS.has(value)) return fallback; return value; } export const CONFIG_FILENAME = "config.json"; export const GLOBAL_CONFIG_DIR_NAME = "deuk-agent-flow"; export const GLOBAL_CONFIG_FILENAME = "config.json"; export const INIT_CONFIG_VERSION = 1; export const WORKSPACE_KINDS = [ { label: "Product planning / strategy / specs", value: "planning" }, { label: "Software engineering / coding", value: "coding" }, { label: "Systems engineering / operations", value: "systems" }, { label: "Research / analysis / knowledge work", value: "research" }, { label: "Mixed team workspace", value: "mixed" }, { label: "Other / decide later", value: "other" }, ]; export const STACKS = [ { label: "Not primarily a code repo", value: "none" }, { label: "Web / product app", value: "web" }, { label: "Backend / service / API", value: "backend" }, { label: "Unity / game / simulation", value: "unity" }, { label: "Data / ML / analysis", value: "data" }, { label: "Infrastructure / platform / SRE", value: "infra" }, { label: "Hybrid / multiple stacks", value: "hybrid" }, { label: "Other / skip", value: "other" }, ]; export const AGENT_TOOLS = [ { label: "OpenAI Codex", value: "codex" }, { label: "GitHub Copilot", value: "copilot" }, { label: "Claude Code", value: "claude" }, { label: "Cursor", value: "cursor" }, { label: "Gemini / Antigravity", value: "gemini" }, { label: "Windsurf", value: "windsurf" }, { label: "JetBrains AI Assistant", value: "jetbrains" }, ]; export const SPOKE_REGISTRY = [ { id: "cursor", detect: (cwd) => existsSync(makePath(cwd, ".cursor")), legacy: ".cursorrules", target: ".cursor/rules/deuk-agent.mdc", format: "mdc" }, { id: "claude", detect: (cwd) => existsSync(makePath(cwd, "CLAUDE.md")) || existsSync(makePath(cwd, ".claude")), legacy: "CLAUDE.md", target: ".claude/rules/deuk-agent.md", format: "markdown" }, { id: "copilot", detect: (cwd, tools = []) => tools.includes("copilot") || existsSync(makePath(cwd, ".github")), legacy: null, target: ".github/copilot-instructions.md", format: "markdown" }, { id: "codex", detect: (cwd, tools = []) => tools.includes("codex") || existsSync(makePath(cwd, ".codex")), legacy: null, target: ".codex/AGENTS.md", format: "markdown" }, { id: "windsurf", detect: (cwd) => existsSync(makePath(cwd, ".windsurf")), legacy: ".windsurfrules", target: ".windsurf/rules/deuk-agent.md", format: "markdown" }, { id: "jetbrains", detect: (cwd) => existsSync(makePath(cwd, ".aiassistant")) || existsSync(makePath(cwd, ".idea")), legacy: null, target: ".aiassistant/rules/deuk-agent.md", format: "markdown" }, { id: "antigravity", detect: (cwd, tools = []) => tools.includes("gemini") || existsSync(makePath(cwd, "GEMINI.md")) || existsSync(makePath(cwd, ".gemini")) || existsSync(makePath(cwd, ".mcp.json")), legacy: "AGENTS.md", target: "GEMINI.md", format: "markdown" } ]; export const DOC_LANGUAGE_CHOICES = [ { label: "Auto (match system locale)", value: "auto" }, { label: "Korean", value: "ko" }, { label: "English", value: "en" }, ]; export function normalizeDocsLanguage(value) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized || normalized === "auto") return "auto"; if (normalized.startsWith("ko") || normalized === "kr" || normalized === "korean") return "ko"; if (normalized.startsWith("en") || normalized === "english") return "en"; return "auto"; } export function inferDocsLanguageFromEnv(env = process.env) { // 1) POSIX 환경변수 우선(Linux/macOS, 또는 사용자가 Windows에서 명시 설정한 경우). const posix = String(env.LANG || env.LC_ALL || env.LC_MESSAGES || "").toLowerCase(); if (posix) return posix.includes("ko") ? "ko" : "en"; // 2) Windows 등 LANG 부재 환경: Node가 OS 로케일을 반영하는 Intl로 폴백. try { const osLocale = String(Intl.DateTimeFormat().resolvedOptions().locale || "").toLowerCase(); if (osLocale.includes("ko")) return "ko"; if (osLocale) return "en"; } catch { // Intl 미지원(초경량 런타임) 시 최종 폴백으로. } // 3) 최종 폴백. return "en"; } export function inferDocsLanguageFromText(text) { const src = String(text || ""); const hangulCount = (src.match(/[\u3131-\u318e\uac00-\ud7a3]/g) || []).length; if (hangulCount >= 2) return "ko"; const latinWords = src.match(/[A-Za-z][A-Za-z'-]*/g) || []; if (latinWords.length >= 2) return "en"; return null; } export function resolveDocsLanguage(value, env = process.env) { const normalized = normalizeDocsLanguage(value); if (normalized === "auto") return inferDocsLanguageFromEnv(env); return normalized; } export function selectLocalizedTemplatePath(baseDir, templateName, docsLanguage = "en") { const normalized = resolveDocsLanguage(docsLanguage); const localizedName = `${templateName.replace(/\.md$/i, "")}.${normalized}.md`; const localizedPath = makePath(baseDir, localizedName); if (existsSync(localizedPath)) return localizedPath; return makePath(baseDir, templateName); } export function loadInitConfig(cwd, opts: CliOpts = {}) { const targets = [ getUserInitConfigPath() ].filter(Boolean); for (const target of targets) { if (!existsSync(target)) continue; try { const j = JSON.parse(readFileSync(target, "utf8")); if (j.version !== INIT_CONFIG_VERSION) continue; if (!j.docsLanguage) j.docsLanguage = "auto"; const workflowMode = normalizeWorkflowMode(j.workflowMode ?? j.approvalState); j.workflowMode = workflowMode; j.approvalState = workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending"; if (j.scmIgnoreDeukAgent === undefined) j.scmIgnoreDeukAgent = !Boolean(j.shareTickets); j.configPath = target; j.configScope = "user"; return j; } catch (err) { if (process.env.DEBUG) console.warn(`[DEBUG] Failed to parse config ${target}:`, err); } } return null; } export function writeInitConfig(cwd, opts) { const p = getUserInitConfigPath(opts); const dir = dirname(p); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const existing = loadInitConfig(cwd) || {}; const workflowMode = normalizeWorkflowMode(opts.workflowMode ?? opts.workflow ?? opts.approvalState ?? opts.approval); const data = { version: INIT_CONFIG_VERSION, workflowMode, approvalState: workflowMode === WORKFLOW_MODE_EXECUTE ? "approved" : "pending", workspaceKind: opts.workspaceKind ?? opts.kind ?? existing.workspaceKind ?? existing.kind, stack: opts.stack ?? existing.stack, kind: opts.workspaceKind ?? opts.kind ?? existing.workspaceKind ?? existing.kind, agentTools: opts.agentTools ?? existing.agentTools, docsLanguage: normalizeDocsLanguage(opts.docsLanguage ?? existing.docsLanguage ?? "auto"), scmIgnoreDeukAgent: opts.scmIgnoreDeukAgent ?? existing.scmIgnoreDeukAgent ?? !Boolean(opts.shareTickets ?? existing.shareTickets ?? false), shareTickets: opts.shareTickets ?? existing.shareTickets ?? false, contextMcp: opts.contextMcp ?? existing.contextMcp ?? "skip", remoteSync: opts.remoteSync ?? existing.remoteSync ?? false, pipelineUrl: opts.pipelineUrl, ignoreDirs: opts.ignoreDirs || existing.ignoreDirs || DEFAULT_IGNORE_DIRS, updatedAt: new Date().toISOString(), }; writeFileSync(p, JSON.stringify(data, null, 2), "utf8"); } // Single source of truth for the current user's home directory. Every home-derived // path (global agent pointers, Claude settings hook, workspace registry) must route // through here so platform/env home policy lives in one place instead of being // sprawled across call sites with divergent fallbacks. // // process.env.HOME is intentionally NOT consulted: the ~/bin/node wrapper delegates // to the Windows node.exe, so homedir() already resolves to the shared Windows home // (C:\Users\joy) under both Windows and WSL. Honoring env.HOME would make a native // WSL node pick /home/joy and break that shared-home contract. Tests inject opts.homeDir. export function resolveUserHome(opts: CliOpts = {}) { return opts.homeDir || homedir(); } // The project's single path-construction primitive. Joins segments and normalizes // the result to the target platform's convention (canonical form = platform-native: // win32 -> "\\", posix -> "/"), regardless of how the inputs were stored ??mixed or // foreign separators are accepted. platform defaults to the running platform; pass // opts.platform to build a path for another platform deterministically (used by // tests so they compare via this same function and stay platform-agnostic). export function makePath(...args) { // Variadic drop-in for path.join: makePath(a, b, c). Also accepts a single // segments array and/or a trailing { platform } options object: // makePath([a, b], { platform: "win32" }) | makePath(a, b, { platform }) let opts: CliOpts = {}; if (args.length > 1 && args[args.length - 1] && typeof args[args.length - 1] === "object" && !Array.isArray(args[args.length - 1])) { opts = args.pop(); } const segments = args.length === 1 && Array.isArray(args[0]) ? args[0] : args; const platform = opts.platform || process.platform; const mod = platform === "win32" ? win32 : posix; // Swap the *foreign* separator to the target's (no collapsing ??keeps leading // "\\\\" UNC prefixes like \\wsl.localhost\... intact); the join then normalizes. const foreign = platform === "win32" ? /\//g : /\\/g; const parts = segments .map(part => String(part ?? "")) .filter(part => part.length > 0) .map(part => part.replace(foreign, mod.sep)); return mod.join(...parts); } // #709: Single source of truth for the package root. Historically 10 call sites computed // this five different ways (dirname(dirname(import.meta.url)), makePath(__dirname, ".."), // resolve(dirname(fileURLToPath()), ".."), ...). Those bake in a fixed directory depth, so // the tsc build output (scripts/out/scripts/) shifted the depth and broke docs/ resolution // ("CLI surface document not found: scripts/out/docs/..."). This walks UP from the caller's // file looking for the package.json marker, so it returns the SAME root whether the code runs // from source (scripts/) or build output (scripts/out/scripts/) — location-independent. let _packageRootCache: string | null = null; export function resolvePackageRoot(opts: CliOpts = {}): string { if (opts.packageRoot) return opts.packageRoot; if (!opts.noCache && _packageRootCache) return _packageRootCache; const startFile = opts.fromUrl ? fileURLToPath(opts.fromUrl) : fileURLToPath(import.meta.url); let dir = dirname(startFile); // Walk up until a package.json is found; stop at filesystem root. while (true) { if (existsSync(makePath(dir, "package.json"))) break; const parent = dirname(dir); if (parent === dir) break; // reached root without a marker — fall back to original dir dir = parent; } if (!opts.noCache) _packageRootCache = dir; return dir; } // #632: OS-agnostic basename. path.basename uses the CURRENT runtime's separator, so // under a POSIX node (WSL / Git Bash) it cannot split a Windows path — basename( // "D:\\workspace\\DeukPack") wrongly returns "workspaceDeukPack". normalizeRegistryPath // emits Windows-style paths regardless of runtime, so any code deriving a name from one // (e.g. computeTicketKey) must split on BOTH separators. Mirrors makePath's cross-OS intent. export function basenameAnyOS(value) { return String(value ?? "").split(/[\\/]/).filter(Boolean).pop() || ""; } export function getUserConfigDir(opts: CliOpts = {}) { const platform = opts.platform || process.platform; const env = opts.env || process.env; const homeDir = resolveUserHome(opts); if (platform === "win32") { const base = env.APPDATA || makePath([homeDir, "AppData", "Roaming"], { platform }); return makePath([base, GLOBAL_CONFIG_DIR_NAME], { platform }); } const base = env.XDG_CONFIG_HOME || makePath([homeDir, ".config"], { platform }); return makePath([base, GLOBAL_CONFIG_DIR_NAME], { platform }); } export function getUserInitConfigPath(opts: CliOpts = {}) { return makePath([getUserConfigDir(opts), GLOBAL_CONFIG_FILENAME], opts); } // Global, workspace-independent directory for user-forked/custom skills. // Lives alongside the global config so personalized skills follow the user // across every workspace (win: %APPDATA%\deuk-agent-flow\skills, // posix: $XDG_CONFIG_HOME|~/.config/deuk-agent-flow/skills). export function getUserSkillsDir(opts: CliOpts = {}) { return makePath([resolveUserHome(opts), DEUK_ROOT_DIR, "skills"], opts); } // Global, workspace-independent path for skill install state (the `installed` // array). Lives next to the global skills dir so a skill installed once shows // the same state across every editor/workspace. Exposure stays per-workspace // because it is physically injected into agent rule files. export function getUserSkillsConfigPath(opts: CliOpts = {}) { return makePath([resolveUserHome(opts), DEUK_ROOT_DIR, "skills.json"], opts); } export function getWorkspaceInitConfigPath(cwd) { return makePath(cwd, deukRel(CONFIG_FILENAME)); } export function migrateWorkspaceInitConfigToUserConfig(cwd, opts: CliOpts = {}) { const workspaceConfig = getWorkspaceInitConfigPath(cwd); if (!existsSync(workspaceConfig)) return false; const dryRun = Boolean(opts.dryRun); const silent = Boolean(opts.silent); if (!dryRun) unlinkSync(workspaceConfig); if (!silent) { const relWorkspaceConfig = toRepoRelativePath(cwd, workspaceConfig); console.log(`[MIGRATE] ${dryRun ? "Would remove" : "Removed"} legacy workspace config: ${relWorkspaceConfig}`); } return dryRun ? "dry-run-remove-workspace-config" : "removed-workspace-config"; } export function normalizeWorkflowMode(value) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) return WORKFLOW_MODE_PLAN; if (["execute", "approved", "approval", "apply", "apply-changes"].includes(normalized)) { return WORKFLOW_MODE_EXECUTE; } if (["plan", "pending", "review", "prepare", "prepare-only"].includes(normalized)) { return WORKFLOW_MODE_PLAN; } return normalized === WORKFLOW_MODE_EXECUTE ? WORKFLOW_MODE_EXECUTE : WORKFLOW_MODE_PLAN; } export function isWorkflowExecute(opts: CliOpts = {}, savedConfig = null) { return normalizeWorkflowMode( opts.workflowMode ?? opts.workflow ?? opts.approval ?? opts.approvalState ?? savedConfig?.workflowMode ?? savedConfig?.approvalState ) === WORKFLOW_MODE_EXECUTE; } /** * Resolves the final workflow mode by checking opts, then saved config, with fallback. */ export function resolveWorkflowMode(opts: CliOpts = {}, savedConfig = null) { return normalizeWorkflowMode( opts.workflowMode ?? opts.workflow ?? opts.approval ?? opts.approvalState ?? savedConfig?.workflowMode ?? savedConfig?.approvalState ); } /** * Strips dynamically appended rule modules from content. */ export function pruneRuleModules(content) { const marker = "