@serviceme/devtools-core 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/skill-linker/index.ts","../src/paths/userHome.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\n\nimport { getHomeDir, getRepoDir } from \"../paths/userHome\";\n\n/**\n * Skill & Agent v2 — Client SkillLinker (M3, path-parity revision)\n *\n * SkillLinker exposes a skill/agent from the user's global repos\n * (`~/.serviceme/repos/<repoId>/{skills,agents}/<name>/`) into either:\n *\n * - a workspace, at `<workspace>/.github/{skills,agents}/<name>` —\n * the same path Copilot/Claude actually scan for `SKILL.md` /\n * `AGENT.md`, so linking here has real effect (see the v1 parity\n * note below); or\n * - the user's global scope, at `~/.agents/{skills,agents}/<name>`,\n * visible to every workspace.\n *\n * **v1 parity note**: an earlier revision linked into a private\n * `<workspace>/.serviceme/skills/<repoId>/<name>` directory. That path\n * is never scanned by the actual tooling, so installs were silently\n * inert. The path was moved to match `.github/skills` (the same\n * location the legacy v1 marketplace pipeline used). Because the link\n * path is now flat (no `<repoId>` segment), only one repo's version of\n * a given `<name>` can be linked into a given scope at a time.\n *\n * Platform behavior (per spec §11.8 — owner explicitly chose throw\n * over silent copy fallback):\n *\n * | platform | default mode | on failure |\n * |----------|--------------|-----------------------------|\n * | macOS | symlink | throw + platform hint |\n * | Linux | symlink | throw + platform hint |\n * | Windows | junction | throw + platform hint |\n *\n * Why junction first on Windows: NTFS junction doesn't require\n * SeCreateSymbolicLinkPrivilege; it works for any user. The limit is\n * the link target must be on a local NTFS volume. OneDrive / network\n * shares sometimes fail; we surface that as a typed error so callers\n * can decide whether to retry.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.6 SkillLinker\n * @see docs/architecture/skill-agent-v2-repo.md §11.8 (throw, not copy)\n */\n\nexport type LinkMode = \"symlink\" | \"junction\" | \"copy\";\n\n/** Kind of artifact being linked. Mirrors `BridgeSkillKind`. */\nexport type EntryKind = \"skill\" | \"agent\";\n\n/**\n * Which scope the link lives in:\n * - `\"workspace\"` (default) → `<workspaceDir>/.github/<kind>/<name>`\n * - `\"user\"` → `~/.agents/<kind>/<name>` (visible to every workspace)\n */\nexport type LinkScope = \"workspace\" | \"user\";\n\nconst ENTRY_SUBDIR: Record<EntryKind, string> = {\n\tskill: \"skills\",\n\tagent: \"agents\",\n};\n\nexport interface InstallSkillOptions {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\t/** Choose link mode explicitly. Defaults to platform-optimal. */\n\tmode?: \"auto\" | LinkMode;\n\t/** \"skill\" (default) or \"agent\". */\n\tkind?: EntryKind;\n\t/** \"workspace\" (default) or \"user\". */\n\tscope?: LinkScope;\n\t/**\n\t * Pre-resolved absolute source path (from `SkillStore`), overriding\n\t * the `<repoRoot>/{skills,agents}/<name>` formula in\n\t * {@link resolveSkillTarget}. Required for repos with non-standard\n\t * layouts — flat root (no `skills/` prefix), nested scope dirs\n\t * (`skills/official/<name>`), or flat single-file agent manifests\n\t * (`agents/<name>.agent.md`) — which the formula can't predict.\n\t * When omitted, falls back to the formula (kept for callers that\n\t * install a hand-authored skill without going through `SkillStore`).\n\t */\n\tsourcePath?: string;\n\t/**\n\t * True when `sourcePath` points at a single manifest FILE (e.g. a\n\t * flat `<name>.agent.md`) rather than a directory. Determines the\n\t * symlink type (file vs dir/junction) and the link's basename (the\n\t * link mirrors the source file's name + extension, since Copilot/\n\t * VS Code scan `.github/agents/<name>.agent.md` as a FILE).\n\t */\n\tsourceIsFile?: boolean;\n}\n\nexport interface InstallSkillResult {\n\tmode: LinkMode;\n\tlinkPath: string;\n\ttargetPath: string;\n}\n\n/** Describes one symlink/junction currently present in a workspace/user scope. */\nexport interface LinkedSkill {\n\t/**\n\t * Repo id the link resolves to, parsed back out of the symlink\n\t * target. Empty string when the target isn't under a recognizable\n\t * `~/.serviceme/repos/<repoId>/...` path.\n\t */\n\trepoId: string;\n\tskillName: string;\n\tlinkPath: string;\n\ttargetPath: string;\n\tmode: LinkMode;\n\t/** Which scope this link was found in — \"workspace\" or \"user\" (global). */\n\tscope: LinkScope;\n}\n\n/**\n * Sentinel error for link failures. Carries the chosen mode + platform\n * hint so the UI can render an actionable message.\n */\nexport class LinkError extends Error {\n\treadonly mode: LinkMode;\n\treadonly target: string;\n\treadonly linkPath: string;\n\treadonly platform: NodeJS.Platform;\n\treadonly cause: unknown;\n\n\tconstructor(opts: {\n\t\tmode: LinkMode;\n\t\ttarget: string;\n\t\tlinkPath: string;\n\t\tplatform: NodeJS.Platform;\n\t\tcause: unknown;\n\t}) {\n\t\tsuper(\n\t\t\t`failed to ${opts.mode} link ${opts.linkPath} → ${opts.target} on ${opts.platform}: ${\n\t\t\t\topts.cause instanceof Error ? opts.cause.message : String(opts.cause)\n\t\t\t}`\n\t\t);\n\t\tthis.name = \"LinkError\";\n\t\tthis.mode = opts.mode;\n\t\tthis.target = opts.target;\n\t\tthis.linkPath = opts.linkPath;\n\t\tthis.platform = opts.platform;\n\t\tthis.cause = opts.cause;\n\t}\n\n\t/** A user-facing hint for the platform that failed. */\n\thint(): string {\n\t\treturn getPlatformHint(this.platform);\n\t}\n}\n\n/** Choose the platform-optimal link mode. */\nexport function pickMode(platform: NodeJS.Platform = process.platform): LinkMode {\n\treturn platform === \"win32\" ? \"junction\" : \"symlink\";\n}\n\n/** A short hint string for the platform where the link failed. */\nexport function getPlatformHint(platform: NodeJS.Platform): string {\n\tif (platform === \"win32\") {\n\t\treturn (\n\t\t\t\"On Windows, enable Developer Mode in Settings → Privacy & Security → \" +\n\t\t\t\"For developers, or run as Administrator. NTFS junction also requires the \" +\n\t\t\t\"target to be on a local NTFS volume (OneDrive / network shares are not supported).\"\n\t\t);\n\t}\n\treturn (\n\t\t\"Symlink creation failed. Check filesystem permissions and that the source \" +\n\t\t\"directory exists. On some filesystems (FAT32, exFAT, network mounts) symlinks \" +\n\t\t\"are not supported.\"\n\t);\n}\n\n/**\n * Resolve the source path inside `~/.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Exported for callers that need to inspect the target without installing.\n */\nexport function resolveSkillTarget(\n\trepoId: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(getRepoDir(repoId), ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the workspace link path — `<workspaceDir>/.github/{skills,agents}/<name>` —\n * the same directory Copilot/Claude actually scan.\n */\nexport function resolveSkillLinkPath(\n\tworkspaceDir: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the user-scope link path — `~/.agents/{skills,agents}/<name>` —\n * visible to every workspace.\n */\nexport function resolveUserSkillLinkPath(skillName: string, kind: EntryKind = \"skill\"): string {\n\treturn path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind], skillName);\n}\n\nfunction resolveLinkPath(opts: {\n\tworkspaceDir: string;\n\tskillName: string;\n\tkind: EntryKind;\n\tscope: LinkScope;\n\t/** When set, use this basename instead of the bare `skillName` — for\n\t * file-based sources the link must mirror the source's filename\n\t * (including its extension) to be scannable at its real path. */\n\tlinkBasename?: string;\n}): string {\n\tif (opts.scope === \"workspace\" && !opts.workspaceDir) {\n\t\tthrow new Error(\n\t\t\t\"workspaceDir is required for scope: 'workspace' (pass scope: 'user' for a global install instead).\"\n\t\t);\n\t}\n\tconst base =\n\t\topts.scope === \"user\"\n\t\t\t? resolveUserSkillLinkPath(opts.skillName, opts.kind)\n\t\t\t: resolveSkillLinkPath(opts.workspaceDir, opts.skillName, opts.kind);\n\treturn opts.linkBasename ? path.join(path.dirname(base), opts.linkBasename) : base;\n}\n\n/**\n * Install a skill into a workspace by creating a link at the standard\n * path. Throws `LinkError` on failure (no silent copy fallback per\n * §11.8).\n *\n * Idempotent: if the correct link already exists, returns the existing\n * record without touching the filesystem. If a *wrong* link exists at\n * the target path, it is replaced.\n */\nexport async function installSkillToWorkspace(\n\topts: InstallSkillOptions\n): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst isFile = opts.sourceIsFile ?? false;\n\tconst target = opts.sourcePath ?? resolveSkillTarget(opts.repoId, opts.skillName, kind);\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: isFile ? path.basename(target) : undefined,\n\t});\n\n\t// Sanity-check: the skill source directory must exist before we try\n\t// to link it. Otherwise the link would dangle.\n\tawait fs.access(target).catch(() => {\n\t\tthrow new LinkError({\n\t\t\tmode: \"symlink\", // placeholder — mode not chosen yet\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: new Error(`skill source not found: ${target}`),\n\t\t});\n\t});\n\n\t// If the link already exists and points to the right target, no-op.\n\tif (await isCorrectLink(linkPath, target)) {\n\t\treturn { mode: \"symlink\", linkPath, targetPath: target };\n\t}\n\t// Otherwise, remove any stale entry at the path.\n\tawait removeIfExists(linkPath);\n\n\t// Junctions are directory-only — a file source can never use one,\n\t// regardless of platform default. Fall back to a real symlink.\n\tconst requestedMode = opts.mode === undefined || opts.mode === \"auto\" ? pickMode() : opts.mode;\n\tconst mode = isFile && requestedMode === \"junction\" ? \"symlink\" : requestedMode;\n\n\t// Ensure the parent directory exists — fs.symlink does NOT create\n\t// missing parents. The path is `<workspace>/.serviceme/skills/<repoId>/`,\n\t// which is several levels deep on a fresh workspace.\n\tawait fs.mkdir(path.dirname(linkPath), { recursive: true });\n\n\ttry {\n\t\tif (mode === \"junction\") {\n\t\t\t// fs.symlink with type \"junction\" works on Windows for dirs.\n\t\t\tawait fs.symlink(target, linkPath, \"junction\");\n\t\t} else if (mode === \"symlink\") {\n\t\t\t// Windows requires an explicit \"file\" type for file symlinks;\n\t\t\t// POSIX ignores the type argument either way.\n\t\t\tawait fs.symlink(target, linkPath, isFile ? \"file\" : \"dir\");\n\t\t} else {\n\t\t\t// `copy` mode is intentionally NOT implemented for v1 — see\n\t\t\t// spec §11.8 (\"throw, not copy fallback\"). Reaching this branch\n\t\t\t// means a caller explicitly passed `mode: 'copy'`; we treat\n\t\t\t// it as a configuration error rather than silently dropping\n\t\t\t// to copy semantics.\n\t\t\tthrow new LinkError({\n\t\t\t\tmode: \"copy\",\n\t\t\t\ttarget,\n\t\t\t\tlinkPath,\n\t\t\t\tplatform: process.platform,\n\t\t\t\tcause: new Error(\n\t\t\t\t\t\"copy mode is not supported in v1 — see spec §11.8 (throw, not copy fallback)\"\n\t\t\t\t),\n\t\t\t});\n\t\t}\n\t} catch (err) {\n\t\tif (err instanceof LinkError) throw err;\n\t\tthrow new LinkError({\n\t\t\tmode,\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: err,\n\t\t});\n\t}\n\n\treturn { mode, linkPath, targetPath: target };\n}\n\n/**\n * Convert an existing, real (non-symlink) skill/agent directory at the\n * standard scan path into a symlink pointing at the matching global\n * repo entry. Refuses to run when there's nothing real to convert —\n * callers should only surface this action when a marketplace/scope\n * state check already confirmed a non-symlink install is present.\n */\nexport async function convertToSymlink(opts: InstallSkillOptions): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: opts.sourceIsFile && opts.sourcePath ? path.basename(opts.sourcePath) : undefined,\n\t});\n\n\tconst stat = await fs.lstat(linkPath).catch(() => null);\n\tif (!stat) {\n\t\tthrow new Error(`Nothing installed at ${linkPath} to convert.`);\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(`${linkPath} is already a symlink.`);\n\t}\n\n\treturn installSkillToWorkspace(opts);\n}\n\n/**\n * Remove a skill/agent link from a workspace/user scope. Idempotent:\n * succeeds when the link does not exist (no error).\n *\n * Tries the bare `<name>` path first (directory-based entries), then\n * falls back to scanning the scope dir for a `<name>.<ext>` file (flat\n * manifest entries, e.g. `CSharpExpert.agent.md`) — the caller doesn't\n * necessarily still have the source repo entry to know which shape was\n * installed (e.g. the repo may have been removed since).\n */\nexport async function uninstallSkillFromWorkspace(opts: {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\tkind?: EntryKind;\n\tscope?: LinkScope;\n}): Promise<void> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t});\n\tif (await pathExists(linkPath)) {\n\t\tawait removeIfExists(linkPath);\n\t\treturn;\n\t}\n\n\tconst scopeDir = path.dirname(linkPath);\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(scopeDir, { withFileTypes: true });\n\t} catch {\n\t\treturn; // nothing installed at all — idempotent no-op\n\t}\n\tconst flatMatch = entries.find(\n\t\t(e) => e.name === opts.skillName || e.name.startsWith(`${opts.skillName}.`)\n\t);\n\tif (flatMatch) {\n\t\tawait removeIfExists(path.join(scopeDir, flatMatch.name));\n\t}\n}\n\n/** `true` when `p` exists (following symlinks is irrelevant — `lstat` is enough). */\nasync function pathExists(p: string): Promise<boolean> {\n\ttry {\n\t\tawait fs.lstat(p);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nconst MANIFEST_FILENAME: Record<EntryKind, string> = {\n\tskill: \"SKILL.md\",\n\tagent: \"AGENT.md\",\n};\n\n/**\n * Enumerate all skill/agent entries currently present in a workspace/user\n * scope — both symlinked installs (`mode: \"symlink\"`/`\"junction\"`) AND\n * real, non-symlink directories/files that already carry a valid\n * manifest (`mode: \"copy\"`). Used by the UI's \"Manage installed skills\"\n * view so it can show a real (copy-installed) entry as installed too —\n * this is common for the user (global) scope, where many skills are\n * placed by other tools/installers as plain copies rather than via this\n * package's symlink-only install flow. `repoId` is `\"\"` for `\"copy\"`\n * entries since there's no symlink target to resolve it from; callers\n * match these by name only. Returns an empty array when the scan\n * directory doesn't exist.\n */\nexport async function listLinkedSkills(\n\tworkspaceDir: string,\n\tkind: EntryKind = \"skill\",\n\tscope: LinkScope = \"workspace\"\n): Promise<LinkedSkill[]> {\n\t// No workspace open + workspace scope requested — nothing to scan.\n\t// Guard explicitly rather than falling through to\n\t// `path.join(\"\", \".github\", ...)`, which resolves to a *relative*\n\t// path (anchored at the process's cwd, not a real workspace) and\n\t// could accidentally match an unrelated directory there.\n\tif (scope === \"workspace\" && !workspaceDir) {\n\t\treturn [];\n\t}\n\tconst root =\n\t\tscope === \"user\"\n\t\t\t? path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind])\n\t\t\t: path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind]);\n\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(root, { withFileTypes: true });\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow err;\n\t}\n\n\tconst results: LinkedSkill[] = [];\n\tfor (const entry of entries) {\n\t\tconst entryPath = path.join(root, entry.name);\n\n\t\tif (entry.isSymbolicLink()) {\n\t\t\t// Flat manifest files (e.g. `CSharpExpert.agent.md`) report the\n\t\t\t// bare name, matching what SkillStore/install use elsewhere —\n\t\t\t// otherwise this would look like a different, unrelated entry.\n\t\t\tconst skillName = entry.name.replace(/\\.(agent|skill)\\.md$/, \"\");\n\t\t\tconst rawTarget = await readLinkTarget(entryPath);\n\t\t\tif (!rawTarget) continue; // unreadable\n\t\t\tconst resolvedTarget = resolveTargetPath(entryPath, rawTarget);\n\t\t\t// Skip dangling links — `fs.stat` follows symlinks and throws\n\t\t\t// ENOENT when the target is missing. That's the signal that\n\t\t\t// the link is broken.\n\t\t\tconst targetStat = await fs.stat(resolvedTarget).catch(() => null);\n\t\t\tif (!targetStat) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: extractRepoIdFromTarget(resolvedTarget) ?? \"\",\n\t\t\t\tskillName,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: resolvedTarget,\n\t\t\t\tmode: process.platform === \"win32\" ? \"junction\" : \"symlink\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isDirectory()) {\n\t\t\t// A real (copy-installed) directory — only counts as an\n\t\t\t// installed entry when it actually carries the expected\n\t\t\t// manifest file, so unrelated junk directories in the scope\n\t\t\t// dir (e.g. leftover `.DS_Store`-adjacent folders) aren't\n\t\t\t// misreported as installed skills/agents.\n\t\t\tconst hasManifest = await fs\n\t\t\t\t.access(path.join(entryPath, MANIFEST_FILENAME[kind]))\n\t\t\t\t.then(() => true)\n\t\t\t\t.catch(() => false);\n\t\t\tif (!hasManifest) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isFile() && entry.name.endsWith(`.${kind}.md`)) {\n\t\t\t// A real (copy-installed) flat manifest file, e.g. a plain\n\t\t\t// `CSharpExpert.agent.md` that was placed directly rather\n\t\t\t// than symlinked.\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name.replace(/\\.(agent|skill)\\.md$/, \"\"),\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t}\n\t}\n\treturn results;\n}\n\n// ─────────────────────────────────────────────────────────────────────\n// Internal helpers\n// ─────────────────────────────────────────────────────────────────────\n\n/** Returns `true` when `linkPath` is a symlink/junction pointing at `expectedTarget`. */\nasync function isCorrectLink(linkPath: string, expectedTarget: string): Promise<boolean> {\n\tlet actual: string | undefined;\n\ttry {\n\t\tactual = await readLinkTarget(linkPath);\n\t} catch {\n\t\treturn false;\n\t}\n\tif (!actual) return false;\n\treturn resolveTargetPath(linkPath, actual) === resolveTargetPath(linkPath, expectedTarget);\n}\n\n/** Read a link's target without throwing — returns undefined on ENOENT. */\nasync function readLinkTarget(linkPath: string): Promise<string | undefined> {\n\ttry {\n\t\treturn await fs.readlink(linkPath);\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n\t\tthrow err;\n\t}\n}\n\n/**\n * Resolve a relative symlink target against its parent directory so a\n * relative link reads back as an absolute path comparable to another\n * absolute path.\n */\nfunction resolveTargetPath(linkPath: string, target: string): string {\n\tif (path.isAbsolute(target)) return path.normalize(target);\n\treturn path.normalize(path.resolve(path.dirname(linkPath), target));\n}\n\n/**\n * Parse the `<repoId>` segment back out of a resolved link target of\n * the shape `.../.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Returns `undefined` when the target doesn't match that shape (e.g. a\n * hand-authored local skill under `~/.agents/skills/<name>` with no\n * backing repo).\n */\nfunction extractRepoIdFromTarget(target: string): string | undefined {\n\tconst marker = `${path.sep}repos${path.sep}`;\n\tconst idx = target.indexOf(marker);\n\tif (idx === -1) return undefined;\n\tconst rest = target.slice(idx + marker.length);\n\tconst [repoId] = rest.split(path.sep);\n\treturn repoId || undefined;\n}\n\n/** Best-effort removal of a path. ENOENT is silently ignored. */\nasync function removeIfExists(p: string): Promise<void> {\n\ttry {\n\t\tconst stat = await fs.lstat(p);\n\t\tif (stat.isDirectory() && !stat.isSymbolicLink()) {\n\t\t\tawait fs.rm(p, { recursive: true, force: true });\n\t\t} else {\n\t\t\tawait fs.unlink(p);\n\t\t}\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return;\n\t\tthrow err;\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n"],"mappings":"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","names":["path","stat"]}
1
+ {"version":3,"sources":["../src/skill-linker/index.ts","../src/paths/userHome.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\n\nimport { getHomeDir, getRepoDir } from \"../paths/userHome\";\n\n/**\n * Skill & Agent v2 — Client SkillLinker (M3, path-parity revision)\n *\n * SkillLinker exposes a skill/agent from the user's global repos\n * (`~/.serviceme/repos/<repoId>/{skills,agents}/<name>/`) into either:\n *\n * - a workspace, at `<workspace>/.github/{skills,agents}/<name>` —\n * the same path Copilot/Claude actually scan for `SKILL.md` /\n * `AGENT.md`, so linking here has real effect (see the v1 parity\n * note below); or\n * - the user's global scope, at `~/.agents/{skills,agents}/<name>`,\n * visible to every workspace.\n *\n * **v1 parity note**: an earlier revision linked into a private\n * `<workspace>/.serviceme/skills/<repoId>/<name>` directory. That path\n * is never scanned by the actual tooling, so installs were silently\n * inert. The path was moved to match `.github/skills` (the same\n * location the legacy v1 marketplace pipeline used). Because the link\n * path is now flat (no `<repoId>` segment), only one repo's version of\n * a given `<name>` can be linked into a given scope at a time.\n *\n * Platform behavior (per spec §11.8 — owner explicitly chose throw\n * over silent copy fallback):\n *\n * | platform | default mode | on failure |\n * |----------|--------------|-----------------------------|\n * | macOS | symlink | throw + platform hint |\n * | Linux | symlink | throw + platform hint |\n * | Windows | junction | throw + platform hint |\n *\n * Why junction first on Windows: NTFS junction doesn't require\n * SeCreateSymbolicLinkPrivilege; it works for any user. The limit is\n * the link target must be on a local NTFS volume. OneDrive / network\n * shares sometimes fail; we surface that as a typed error so callers\n * can decide whether to retry.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.6 SkillLinker\n * @see docs/architecture/skill-agent-v2-repo.md §11.8 (throw, not copy)\n */\n\nexport type LinkMode = \"symlink\" | \"junction\" | \"copy\";\n\n/** Kind of artifact being linked. Mirrors `BridgeSkillKind`. */\nexport type EntryKind = \"skill\" | \"agent\";\n\n/**\n * Which scope the link lives in:\n * - `\"workspace\"` (default) → `<workspaceDir>/.github/<kind>/<name>`\n * - `\"user\"` → `~/.agents/<kind>/<name>` (visible to every workspace)\n */\nexport type LinkScope = \"workspace\" | \"user\";\n\nconst ENTRY_SUBDIR: Record<EntryKind, string> = {\n\tskill: \"skills\",\n\tagent: \"agents\",\n};\n\nexport interface InstallSkillOptions {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\t/** Choose link mode explicitly. Defaults to platform-optimal. */\n\tmode?: \"auto\" | LinkMode;\n\t/** \"skill\" (default) or \"agent\". */\n\tkind?: EntryKind;\n\t/** \"workspace\" (default) or \"user\". */\n\tscope?: LinkScope;\n\t/**\n\t * Pre-resolved absolute source path (from `SkillStore`), overriding\n\t * the `<repoRoot>/{skills,agents}/<name>` formula in\n\t * {@link resolveSkillTarget}. Required for repos with non-standard\n\t * layouts — flat root (no `skills/` prefix), nested scope dirs\n\t * (`skills/official/<name>`), or flat single-file agent manifests\n\t * (`agents/<name>.agent.md`) — which the formula can't predict.\n\t * When omitted, falls back to the formula (kept for callers that\n\t * install a hand-authored skill without going through `SkillStore`).\n\t */\n\tsourcePath?: string;\n\t/**\n\t * True when `sourcePath` points at a single manifest FILE (e.g. a\n\t * flat `<name>.agent.md`) rather than a directory. Determines the\n\t * symlink type (file vs dir/junction) and the link's basename (the\n\t * link mirrors the source file's name + extension, since Copilot/\n\t * VS Code scan `.github/agents/<name>.agent.md` as a FILE).\n\t */\n\tsourceIsFile?: boolean;\n}\n\nexport interface InstallSkillResult {\n\tmode: LinkMode;\n\tlinkPath: string;\n\ttargetPath: string;\n}\n\n/** Describes one symlink/junction currently present in a workspace/user scope. */\nexport interface LinkedSkill {\n\t/**\n\t * Repo id the link resolves to, parsed back out of the symlink\n\t * target. Empty string when the target isn't under a recognizable\n\t * `~/.serviceme/repos/<repoId>/...` path.\n\t */\n\trepoId: string;\n\tskillName: string;\n\tlinkPath: string;\n\ttargetPath: string;\n\tmode: LinkMode;\n\t/** Which scope this link was found in — \"workspace\" or \"user\" (global). */\n\tscope: LinkScope;\n}\n\n/**\n * Sentinel error for link failures. Carries the chosen mode + platform\n * hint so the UI can render an actionable message.\n */\nexport class LinkError extends Error {\n\treadonly mode: LinkMode;\n\treadonly target: string;\n\treadonly linkPath: string;\n\treadonly platform: NodeJS.Platform;\n\treadonly cause: unknown;\n\n\tconstructor(opts: {\n\t\tmode: LinkMode;\n\t\ttarget: string;\n\t\tlinkPath: string;\n\t\tplatform: NodeJS.Platform;\n\t\tcause: unknown;\n\t}) {\n\t\tsuper(\n\t\t\t`failed to ${opts.mode} link ${opts.linkPath} → ${opts.target} on ${opts.platform}: ${\n\t\t\t\topts.cause instanceof Error ? opts.cause.message : String(opts.cause)\n\t\t\t}`\n\t\t);\n\t\tthis.name = \"LinkError\";\n\t\tthis.mode = opts.mode;\n\t\tthis.target = opts.target;\n\t\tthis.linkPath = opts.linkPath;\n\t\tthis.platform = opts.platform;\n\t\tthis.cause = opts.cause;\n\t}\n\n\t/** A user-facing hint for the platform that failed. */\n\thint(): string {\n\t\treturn getPlatformHint(this.platform);\n\t}\n}\n\n/** Choose the platform-optimal link mode. */\nexport function pickMode(platform: NodeJS.Platform = process.platform): LinkMode {\n\treturn platform === \"win32\" ? \"junction\" : \"symlink\";\n}\n\n/** A short hint string for the platform where the link failed. */\nexport function getPlatformHint(platform: NodeJS.Platform): string {\n\tif (platform === \"win32\") {\n\t\treturn (\n\t\t\t\"On Windows, enable Developer Mode in Settings → Privacy & Security → \" +\n\t\t\t\"For developers, or run as Administrator. NTFS junction also requires the \" +\n\t\t\t\"target to be on a local NTFS volume (OneDrive / network shares are not supported).\"\n\t\t);\n\t}\n\treturn (\n\t\t\"Symlink creation failed. Check filesystem permissions and that the source \" +\n\t\t\"directory exists. On some filesystems (FAT32, exFAT, network mounts) symlinks \" +\n\t\t\"are not supported.\"\n\t);\n}\n\n/**\n * Resolve the source path inside `~/.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Exported for callers that need to inspect the target without installing.\n */\nexport function resolveSkillTarget(\n\trepoId: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(getRepoDir(repoId), ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the workspace link path — `<workspaceDir>/.github/{skills,agents}/<name>` —\n * the same directory Copilot/Claude actually scan.\n */\nexport function resolveSkillLinkPath(\n\tworkspaceDir: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the user-scope link path — `~/.agents/{skills,agents}/<name>` —\n * visible to every workspace.\n */\nexport function resolveUserSkillLinkPath(skillName: string, kind: EntryKind = \"skill\"): string {\n\treturn path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind], skillName);\n}\n\nfunction resolveLinkPath(opts: {\n\tworkspaceDir: string;\n\tskillName: string;\n\tkind: EntryKind;\n\tscope: LinkScope;\n\t/** When set, use this basename instead of the bare `skillName` — for\n\t * file-based sources the link must mirror the source's filename\n\t * (including its extension) to be scannable at its real path. */\n\tlinkBasename?: string;\n}): string {\n\tif (opts.scope === \"workspace\" && !opts.workspaceDir) {\n\t\tthrow new Error(\n\t\t\t\"workspaceDir is required for scope: 'workspace' (pass scope: 'user' for a global install instead).\"\n\t\t);\n\t}\n\tconst base =\n\t\topts.scope === \"user\"\n\t\t\t? resolveUserSkillLinkPath(opts.skillName, opts.kind)\n\t\t\t: resolveSkillLinkPath(opts.workspaceDir, opts.skillName, opts.kind);\n\treturn opts.linkBasename ? path.join(path.dirname(base), opts.linkBasename) : base;\n}\n\n/**\n * Install a skill into a workspace by creating a link at the standard\n * path. Throws `LinkError` on failure (no silent copy fallback per\n * §11.8).\n *\n * Idempotent: if the correct link already exists, returns the existing\n * record without touching the filesystem. If a *wrong* link exists at\n * the target path, it is replaced.\n */\nexport async function installSkillToWorkspace(\n\topts: InstallSkillOptions\n): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst isFile = opts.sourceIsFile ?? false;\n\tconst target = opts.sourcePath ?? resolveSkillTarget(opts.repoId, opts.skillName, kind);\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: isFile ? path.basename(target) : undefined,\n\t});\n\n\t// Sanity-check: the skill source directory must exist before we try\n\t// to link it. Otherwise the link would dangle.\n\tawait fs.access(target).catch(() => {\n\t\tthrow new LinkError({\n\t\t\tmode: \"symlink\", // placeholder — mode not chosen yet\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: new Error(`skill source not found: ${target}`),\n\t\t});\n\t});\n\n\t// If the link already exists and points to the right target, no-op.\n\tif (await isCorrectLink(linkPath, target)) {\n\t\treturn { mode: \"symlink\", linkPath, targetPath: target };\n\t}\n\t// Otherwise, remove any stale entry at the path.\n\tawait removeIfExists(linkPath);\n\n\t// Junctions are directory-only — a file source can never use one,\n\t// regardless of platform default. Fall back to a real symlink.\n\tconst requestedMode = opts.mode === undefined || opts.mode === \"auto\" ? pickMode() : opts.mode;\n\tconst mode = isFile && requestedMode === \"junction\" ? \"symlink\" : requestedMode;\n\n\t// Ensure the parent directory exists — fs.symlink does NOT create\n\t// missing parents. The path is `<workspace>/.serviceme/skills/<repoId>/`,\n\t// which is several levels deep on a fresh workspace.\n\tawait fs.mkdir(path.dirname(linkPath), { recursive: true });\n\n\ttry {\n\t\tif (mode === \"junction\") {\n\t\t\t// fs.symlink with type \"junction\" works on Windows for dirs.\n\t\t\tawait fs.symlink(target, linkPath, \"junction\");\n\t\t} else if (mode === \"symlink\") {\n\t\t\t// Windows requires an explicit \"file\" type for file symlinks;\n\t\t\t// POSIX ignores the type argument either way.\n\t\t\tawait fs.symlink(target, linkPath, isFile ? \"file\" : \"dir\");\n\t\t} else {\n\t\t\t// `copy` mode is intentionally NOT implemented for v1 — see\n\t\t\t// spec §11.8 (\"throw, not copy fallback\"). Reaching this branch\n\t\t\t// means a caller explicitly passed `mode: 'copy'`; we treat\n\t\t\t// it as a configuration error rather than silently dropping\n\t\t\t// to copy semantics.\n\t\t\tthrow new LinkError({\n\t\t\t\tmode: \"copy\",\n\t\t\t\ttarget,\n\t\t\t\tlinkPath,\n\t\t\t\tplatform: process.platform,\n\t\t\t\tcause: new Error(\n\t\t\t\t\t\"copy mode is not supported in v1 — see spec §11.8 (throw, not copy fallback)\"\n\t\t\t\t),\n\t\t\t});\n\t\t}\n\t} catch (err) {\n\t\tif (err instanceof LinkError) throw err;\n\t\tthrow new LinkError({\n\t\t\tmode,\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: err,\n\t\t});\n\t}\n\n\treturn { mode, linkPath, targetPath: target };\n}\n\n/**\n * Convert an existing, real (non-symlink) skill/agent directory at the\n * standard scan path into a symlink pointing at the matching global\n * repo entry. Refuses to run when there's nothing real to convert —\n * callers should only surface this action when a marketplace/scope\n * state check already confirmed a non-symlink install is present.\n */\nexport async function convertToSymlink(opts: InstallSkillOptions): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: opts.sourceIsFile && opts.sourcePath ? path.basename(opts.sourcePath) : undefined,\n\t});\n\n\tconst stat = await fs.lstat(linkPath).catch(() => null);\n\tif (!stat) {\n\t\tthrow new Error(`Nothing installed at ${linkPath} to convert.`);\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(`${linkPath} is already a symlink.`);\n\t}\n\n\treturn installSkillToWorkspace(opts);\n}\n\n/**\n * Remove a skill/agent link from a workspace/user scope. Idempotent:\n * succeeds when the link does not exist (no error).\n *\n * Tries the bare `<name>` path first (directory-based entries), then\n * falls back to scanning the scope dir for a `<name>.<ext>` file (flat\n * manifest entries, e.g. `CSharpExpert.agent.md`) — the caller doesn't\n * necessarily still have the source repo entry to know which shape was\n * installed (e.g. the repo may have been removed since).\n */\nexport async function uninstallSkillFromWorkspace(opts: {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\tkind?: EntryKind;\n\tscope?: LinkScope;\n}): Promise<void> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t});\n\tif (await pathExists(linkPath)) {\n\t\tawait removeIfExists(linkPath);\n\t\treturn;\n\t}\n\n\tconst scopeDir = path.dirname(linkPath);\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(scopeDir, { withFileTypes: true });\n\t} catch {\n\t\treturn; // nothing installed at all — idempotent no-op\n\t}\n\tconst flatMatch = entries.find(\n\t\t(e) => e.name === opts.skillName || e.name.startsWith(`${opts.skillName}.`)\n\t);\n\tif (flatMatch) {\n\t\tawait removeIfExists(path.join(scopeDir, flatMatch.name));\n\t}\n}\n\n/** `true` when `p` exists (following symlinks is irrelevant — `lstat` is enough). */\nasync function pathExists(p: string): Promise<boolean> {\n\ttry {\n\t\tawait fs.lstat(p);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nconst MANIFEST_FILENAME: Record<EntryKind, string> = {\n\tskill: \"SKILL.md\",\n\tagent: \"AGENT.md\",\n};\n\n/**\n * Enumerate all skill/agent entries currently present in a workspace/user\n * scope — both symlinked installs (`mode: \"symlink\"`/`\"junction\"`) AND\n * real, non-symlink directories/files that already carry a valid\n * manifest (`mode: \"copy\"`). Used by the UI's \"Manage installed skills\"\n * view so it can show a real (copy-installed) entry as installed too —\n * this is common for the user (global) scope, where many skills are\n * placed by other tools/installers as plain copies rather than via this\n * package's symlink-only install flow. `repoId` is `\"\"` for `\"copy\"`\n * entries since there's no symlink target to resolve it from; callers\n * match these by name only. Returns an empty array when the scan\n * directory doesn't exist.\n */\nexport async function listLinkedSkills(\n\tworkspaceDir: string,\n\tkind: EntryKind = \"skill\",\n\tscope: LinkScope = \"workspace\"\n): Promise<LinkedSkill[]> {\n\t// No workspace open + workspace scope requested — nothing to scan.\n\t// Guard explicitly rather than falling through to\n\t// `path.join(\"\", \".github\", ...)`, which resolves to a *relative*\n\t// path (anchored at the process's cwd, not a real workspace) and\n\t// could accidentally match an unrelated directory there.\n\tif (scope === \"workspace\" && !workspaceDir) {\n\t\treturn [];\n\t}\n\tconst root =\n\t\tscope === \"user\"\n\t\t\t? path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind])\n\t\t\t: path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind]);\n\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(root, { withFileTypes: true });\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow err;\n\t}\n\n\tconst results: LinkedSkill[] = [];\n\tfor (const entry of entries) {\n\t\tconst entryPath = path.join(root, entry.name);\n\n\t\tif (entry.isSymbolicLink()) {\n\t\t\t// Flat manifest files (e.g. `CSharpExpert.agent.md`) report the\n\t\t\t// bare name, matching what SkillStore/install use elsewhere —\n\t\t\t// otherwise this would look like a different, unrelated entry.\n\t\t\tconst skillName = entry.name.replace(/\\.(agent|skill)\\.md$/, \"\");\n\t\t\tconst rawTarget = await readLinkTarget(entryPath);\n\t\t\tif (!rawTarget) continue; // unreadable\n\t\t\tconst resolvedTarget = resolveTargetPath(entryPath, rawTarget);\n\t\t\t// Skip dangling links — `fs.stat` follows symlinks and throws\n\t\t\t// ENOENT when the target is missing. That's the signal that\n\t\t\t// the link is broken.\n\t\t\tconst targetStat = await fs.stat(resolvedTarget).catch(() => null);\n\t\t\tif (!targetStat) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: extractRepoIdFromTarget(resolvedTarget) ?? \"\",\n\t\t\t\tskillName,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: resolvedTarget,\n\t\t\t\tmode: process.platform === \"win32\" ? \"junction\" : \"symlink\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isDirectory()) {\n\t\t\t// A real (copy-installed) directory — only counts as an\n\t\t\t// installed entry when it actually carries the expected\n\t\t\t// manifest file, so unrelated junk directories in the scope\n\t\t\t// dir (e.g. leftover `.DS_Store`-adjacent folders) aren't\n\t\t\t// misreported as installed skills/agents.\n\t\t\tconst hasManifest = await fs\n\t\t\t\t.access(path.join(entryPath, MANIFEST_FILENAME[kind]))\n\t\t\t\t.then(() => true)\n\t\t\t\t.catch(() => false);\n\t\t\tif (!hasManifest) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isFile() && entry.name.endsWith(`.${kind}.md`)) {\n\t\t\t// A real (copy-installed) flat manifest file, e.g. a plain\n\t\t\t// `CSharpExpert.agent.md` that was placed directly rather\n\t\t\t// than symlinked.\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name.replace(/\\.(agent|skill)\\.md$/, \"\"),\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t}\n\t}\n\treturn results;\n}\n\n// ─────────────────────────────────────────────────────────────────────\n// Internal helpers\n// ─────────────────────────────────────────────────────────────────────\n\n/** Returns `true` when `linkPath` is a symlink/junction pointing at `expectedTarget`. */\nasync function isCorrectLink(linkPath: string, expectedTarget: string): Promise<boolean> {\n\tlet actual: string | undefined;\n\ttry {\n\t\tactual = await readLinkTarget(linkPath);\n\t} catch {\n\t\treturn false;\n\t}\n\tif (!actual) return false;\n\treturn resolveTargetPath(linkPath, actual) === resolveTargetPath(linkPath, expectedTarget);\n}\n\n/** Read a link's target without throwing — returns undefined on ENOENT. */\nasync function readLinkTarget(linkPath: string): Promise<string | undefined> {\n\ttry {\n\t\treturn await fs.readlink(linkPath);\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n\t\tthrow err;\n\t}\n}\n\n/**\n * Resolve a relative symlink target against its parent directory so a\n * relative link reads back as an absolute path comparable to another\n * absolute path.\n */\nfunction resolveTargetPath(linkPath: string, target: string): string {\n\tif (path.isAbsolute(target)) return path.normalize(target);\n\treturn path.normalize(path.resolve(path.dirname(linkPath), target));\n}\n\n/**\n * Parse the `<repoId>` segment back out of a resolved link target of\n * the shape `.../.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Returns `undefined` when the target doesn't match that shape (e.g. a\n * hand-authored local skill under `~/.agents/skills/<name>` with no\n * backing repo).\n */\nfunction extractRepoIdFromTarget(target: string): string | undefined {\n\tconst marker = `${path.sep}repos${path.sep}`;\n\tconst idx = target.indexOf(marker);\n\tif (idx === -1) return undefined;\n\tconst rest = target.slice(idx + marker.length);\n\tconst [repoId] = rest.split(path.sep);\n\treturn repoId || undefined;\n}\n\n/** Best-effort removal of a path. ENOENT is silently ignored. */\nasync function removeIfExists(p: string): Promise<void> {\n\ttry {\n\t\tconst stat = await fs.lstat(p);\n\t\tif (stat.isDirectory() && !stat.isSymbolicLink()) {\n\t\t\tawait fs.rm(p, { recursive: true, force: true });\n\t\t} else {\n\t\t\tawait fs.unlink(p);\n\t\t}\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return;\n\t\tthrow err;\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/** rev.20 — r7 BYOM Server Proxy toggle self-state (read by ext + CLI + server). */\nexport const SERVER_PROXY_GLOBAL_FILENAME = \"server-proxy.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── r7 BYOM Server Proxy (rev.20) ─────────────────────────────────────────\n\n/**\n * `~/.serviceme/server-proxy.json` — r7 BYOM Server Proxy toggle\n * self-state. Written by the extension's `ProxyConfigService`, readable\n * by the CLI / Server so they can decide whether to route traffic\n * through the corporate proxy without booting VS Code.\n *\n * Lives under user-level `~/.serviceme/` (not under a workspace) because\n * the toggle is a *user preference* — the same human enabling it on\n * machine A should be able to rely on it on machine B after sync, not\n * have it disappear when they switch repos.\n */\nexport function getServerProxyGlobalPath(): string {\n\treturn path.join(getServicemeHome(), SERVER_PROXY_GLOBAL_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n"],"mappings":"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","names":["path","stat"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/skill-linker/index.ts","../src/paths/userHome.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\n\nimport { getHomeDir, getRepoDir } from \"../paths/userHome\";\n\n/**\n * Skill & Agent v2 — Client SkillLinker (M3, path-parity revision)\n *\n * SkillLinker exposes a skill/agent from the user's global repos\n * (`~/.serviceme/repos/<repoId>/{skills,agents}/<name>/`) into either:\n *\n * - a workspace, at `<workspace>/.github/{skills,agents}/<name>` —\n * the same path Copilot/Claude actually scan for `SKILL.md` /\n * `AGENT.md`, so linking here has real effect (see the v1 parity\n * note below); or\n * - the user's global scope, at `~/.agents/{skills,agents}/<name>`,\n * visible to every workspace.\n *\n * **v1 parity note**: an earlier revision linked into a private\n * `<workspace>/.serviceme/skills/<repoId>/<name>` directory. That path\n * is never scanned by the actual tooling, so installs were silently\n * inert. The path was moved to match `.github/skills` (the same\n * location the legacy v1 marketplace pipeline used). Because the link\n * path is now flat (no `<repoId>` segment), only one repo's version of\n * a given `<name>` can be linked into a given scope at a time.\n *\n * Platform behavior (per spec §11.8 — owner explicitly chose throw\n * over silent copy fallback):\n *\n * | platform | default mode | on failure |\n * |----------|--------------|-----------------------------|\n * | macOS | symlink | throw + platform hint |\n * | Linux | symlink | throw + platform hint |\n * | Windows | junction | throw + platform hint |\n *\n * Why junction first on Windows: NTFS junction doesn't require\n * SeCreateSymbolicLinkPrivilege; it works for any user. The limit is\n * the link target must be on a local NTFS volume. OneDrive / network\n * shares sometimes fail; we surface that as a typed error so callers\n * can decide whether to retry.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.6 SkillLinker\n * @see docs/architecture/skill-agent-v2-repo.md §11.8 (throw, not copy)\n */\n\nexport type LinkMode = \"symlink\" | \"junction\" | \"copy\";\n\n/** Kind of artifact being linked. Mirrors `BridgeSkillKind`. */\nexport type EntryKind = \"skill\" | \"agent\";\n\n/**\n * Which scope the link lives in:\n * - `\"workspace\"` (default) → `<workspaceDir>/.github/<kind>/<name>`\n * - `\"user\"` → `~/.agents/<kind>/<name>` (visible to every workspace)\n */\nexport type LinkScope = \"workspace\" | \"user\";\n\nconst ENTRY_SUBDIR: Record<EntryKind, string> = {\n\tskill: \"skills\",\n\tagent: \"agents\",\n};\n\nexport interface InstallSkillOptions {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\t/** Choose link mode explicitly. Defaults to platform-optimal. */\n\tmode?: \"auto\" | LinkMode;\n\t/** \"skill\" (default) or \"agent\". */\n\tkind?: EntryKind;\n\t/** \"workspace\" (default) or \"user\". */\n\tscope?: LinkScope;\n\t/**\n\t * Pre-resolved absolute source path (from `SkillStore`), overriding\n\t * the `<repoRoot>/{skills,agents}/<name>` formula in\n\t * {@link resolveSkillTarget}. Required for repos with non-standard\n\t * layouts — flat root (no `skills/` prefix), nested scope dirs\n\t * (`skills/official/<name>`), or flat single-file agent manifests\n\t * (`agents/<name>.agent.md`) — which the formula can't predict.\n\t * When omitted, falls back to the formula (kept for callers that\n\t * install a hand-authored skill without going through `SkillStore`).\n\t */\n\tsourcePath?: string;\n\t/**\n\t * True when `sourcePath` points at a single manifest FILE (e.g. a\n\t * flat `<name>.agent.md`) rather than a directory. Determines the\n\t * symlink type (file vs dir/junction) and the link's basename (the\n\t * link mirrors the source file's name + extension, since Copilot/\n\t * VS Code scan `.github/agents/<name>.agent.md` as a FILE).\n\t */\n\tsourceIsFile?: boolean;\n}\n\nexport interface InstallSkillResult {\n\tmode: LinkMode;\n\tlinkPath: string;\n\ttargetPath: string;\n}\n\n/** Describes one symlink/junction currently present in a workspace/user scope. */\nexport interface LinkedSkill {\n\t/**\n\t * Repo id the link resolves to, parsed back out of the symlink\n\t * target. Empty string when the target isn't under a recognizable\n\t * `~/.serviceme/repos/<repoId>/...` path.\n\t */\n\trepoId: string;\n\tskillName: string;\n\tlinkPath: string;\n\ttargetPath: string;\n\tmode: LinkMode;\n\t/** Which scope this link was found in — \"workspace\" or \"user\" (global). */\n\tscope: LinkScope;\n}\n\n/**\n * Sentinel error for link failures. Carries the chosen mode + platform\n * hint so the UI can render an actionable message.\n */\nexport class LinkError extends Error {\n\treadonly mode: LinkMode;\n\treadonly target: string;\n\treadonly linkPath: string;\n\treadonly platform: NodeJS.Platform;\n\treadonly cause: unknown;\n\n\tconstructor(opts: {\n\t\tmode: LinkMode;\n\t\ttarget: string;\n\t\tlinkPath: string;\n\t\tplatform: NodeJS.Platform;\n\t\tcause: unknown;\n\t}) {\n\t\tsuper(\n\t\t\t`failed to ${opts.mode} link ${opts.linkPath} → ${opts.target} on ${opts.platform}: ${\n\t\t\t\topts.cause instanceof Error ? opts.cause.message : String(opts.cause)\n\t\t\t}`\n\t\t);\n\t\tthis.name = \"LinkError\";\n\t\tthis.mode = opts.mode;\n\t\tthis.target = opts.target;\n\t\tthis.linkPath = opts.linkPath;\n\t\tthis.platform = opts.platform;\n\t\tthis.cause = opts.cause;\n\t}\n\n\t/** A user-facing hint for the platform that failed. */\n\thint(): string {\n\t\treturn getPlatformHint(this.platform);\n\t}\n}\n\n/** Choose the platform-optimal link mode. */\nexport function pickMode(platform: NodeJS.Platform = process.platform): LinkMode {\n\treturn platform === \"win32\" ? \"junction\" : \"symlink\";\n}\n\n/** A short hint string for the platform where the link failed. */\nexport function getPlatformHint(platform: NodeJS.Platform): string {\n\tif (platform === \"win32\") {\n\t\treturn (\n\t\t\t\"On Windows, enable Developer Mode in Settings → Privacy & Security → \" +\n\t\t\t\"For developers, or run as Administrator. NTFS junction also requires the \" +\n\t\t\t\"target to be on a local NTFS volume (OneDrive / network shares are not supported).\"\n\t\t);\n\t}\n\treturn (\n\t\t\"Symlink creation failed. Check filesystem permissions and that the source \" +\n\t\t\"directory exists. On some filesystems (FAT32, exFAT, network mounts) symlinks \" +\n\t\t\"are not supported.\"\n\t);\n}\n\n/**\n * Resolve the source path inside `~/.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Exported for callers that need to inspect the target without installing.\n */\nexport function resolveSkillTarget(\n\trepoId: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(getRepoDir(repoId), ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the workspace link path — `<workspaceDir>/.github/{skills,agents}/<name>` —\n * the same directory Copilot/Claude actually scan.\n */\nexport function resolveSkillLinkPath(\n\tworkspaceDir: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the user-scope link path — `~/.agents/{skills,agents}/<name>` —\n * visible to every workspace.\n */\nexport function resolveUserSkillLinkPath(skillName: string, kind: EntryKind = \"skill\"): string {\n\treturn path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind], skillName);\n}\n\nfunction resolveLinkPath(opts: {\n\tworkspaceDir: string;\n\tskillName: string;\n\tkind: EntryKind;\n\tscope: LinkScope;\n\t/** When set, use this basename instead of the bare `skillName` — for\n\t * file-based sources the link must mirror the source's filename\n\t * (including its extension) to be scannable at its real path. */\n\tlinkBasename?: string;\n}): string {\n\tif (opts.scope === \"workspace\" && !opts.workspaceDir) {\n\t\tthrow new Error(\n\t\t\t\"workspaceDir is required for scope: 'workspace' (pass scope: 'user' for a global install instead).\"\n\t\t);\n\t}\n\tconst base =\n\t\topts.scope === \"user\"\n\t\t\t? resolveUserSkillLinkPath(opts.skillName, opts.kind)\n\t\t\t: resolveSkillLinkPath(opts.workspaceDir, opts.skillName, opts.kind);\n\treturn opts.linkBasename ? path.join(path.dirname(base), opts.linkBasename) : base;\n}\n\n/**\n * Install a skill into a workspace by creating a link at the standard\n * path. Throws `LinkError` on failure (no silent copy fallback per\n * §11.8).\n *\n * Idempotent: if the correct link already exists, returns the existing\n * record without touching the filesystem. If a *wrong* link exists at\n * the target path, it is replaced.\n */\nexport async function installSkillToWorkspace(\n\topts: InstallSkillOptions\n): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst isFile = opts.sourceIsFile ?? false;\n\tconst target = opts.sourcePath ?? resolveSkillTarget(opts.repoId, opts.skillName, kind);\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: isFile ? path.basename(target) : undefined,\n\t});\n\n\t// Sanity-check: the skill source directory must exist before we try\n\t// to link it. Otherwise the link would dangle.\n\tawait fs.access(target).catch(() => {\n\t\tthrow new LinkError({\n\t\t\tmode: \"symlink\", // placeholder — mode not chosen yet\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: new Error(`skill source not found: ${target}`),\n\t\t});\n\t});\n\n\t// If the link already exists and points to the right target, no-op.\n\tif (await isCorrectLink(linkPath, target)) {\n\t\treturn { mode: \"symlink\", linkPath, targetPath: target };\n\t}\n\t// Otherwise, remove any stale entry at the path.\n\tawait removeIfExists(linkPath);\n\n\t// Junctions are directory-only — a file source can never use one,\n\t// regardless of platform default. Fall back to a real symlink.\n\tconst requestedMode = opts.mode === undefined || opts.mode === \"auto\" ? pickMode() : opts.mode;\n\tconst mode = isFile && requestedMode === \"junction\" ? \"symlink\" : requestedMode;\n\n\t// Ensure the parent directory exists — fs.symlink does NOT create\n\t// missing parents. The path is `<workspace>/.serviceme/skills/<repoId>/`,\n\t// which is several levels deep on a fresh workspace.\n\tawait fs.mkdir(path.dirname(linkPath), { recursive: true });\n\n\ttry {\n\t\tif (mode === \"junction\") {\n\t\t\t// fs.symlink with type \"junction\" works on Windows for dirs.\n\t\t\tawait fs.symlink(target, linkPath, \"junction\");\n\t\t} else if (mode === \"symlink\") {\n\t\t\t// Windows requires an explicit \"file\" type for file symlinks;\n\t\t\t// POSIX ignores the type argument either way.\n\t\t\tawait fs.symlink(target, linkPath, isFile ? \"file\" : \"dir\");\n\t\t} else {\n\t\t\t// `copy` mode is intentionally NOT implemented for v1 — see\n\t\t\t// spec §11.8 (\"throw, not copy fallback\"). Reaching this branch\n\t\t\t// means a caller explicitly passed `mode: 'copy'`; we treat\n\t\t\t// it as a configuration error rather than silently dropping\n\t\t\t// to copy semantics.\n\t\t\tthrow new LinkError({\n\t\t\t\tmode: \"copy\",\n\t\t\t\ttarget,\n\t\t\t\tlinkPath,\n\t\t\t\tplatform: process.platform,\n\t\t\t\tcause: new Error(\n\t\t\t\t\t\"copy mode is not supported in v1 — see spec §11.8 (throw, not copy fallback)\"\n\t\t\t\t),\n\t\t\t});\n\t\t}\n\t} catch (err) {\n\t\tif (err instanceof LinkError) throw err;\n\t\tthrow new LinkError({\n\t\t\tmode,\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: err,\n\t\t});\n\t}\n\n\treturn { mode, linkPath, targetPath: target };\n}\n\n/**\n * Convert an existing, real (non-symlink) skill/agent directory at the\n * standard scan path into a symlink pointing at the matching global\n * repo entry. Refuses to run when there's nothing real to convert —\n * callers should only surface this action when a marketplace/scope\n * state check already confirmed a non-symlink install is present.\n */\nexport async function convertToSymlink(opts: InstallSkillOptions): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: opts.sourceIsFile && opts.sourcePath ? path.basename(opts.sourcePath) : undefined,\n\t});\n\n\tconst stat = await fs.lstat(linkPath).catch(() => null);\n\tif (!stat) {\n\t\tthrow new Error(`Nothing installed at ${linkPath} to convert.`);\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(`${linkPath} is already a symlink.`);\n\t}\n\n\treturn installSkillToWorkspace(opts);\n}\n\n/**\n * Remove a skill/agent link from a workspace/user scope. Idempotent:\n * succeeds when the link does not exist (no error).\n *\n * Tries the bare `<name>` path first (directory-based entries), then\n * falls back to scanning the scope dir for a `<name>.<ext>` file (flat\n * manifest entries, e.g. `CSharpExpert.agent.md`) — the caller doesn't\n * necessarily still have the source repo entry to know which shape was\n * installed (e.g. the repo may have been removed since).\n */\nexport async function uninstallSkillFromWorkspace(opts: {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\tkind?: EntryKind;\n\tscope?: LinkScope;\n}): Promise<void> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t});\n\tif (await pathExists(linkPath)) {\n\t\tawait removeIfExists(linkPath);\n\t\treturn;\n\t}\n\n\tconst scopeDir = path.dirname(linkPath);\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(scopeDir, { withFileTypes: true });\n\t} catch {\n\t\treturn; // nothing installed at all — idempotent no-op\n\t}\n\tconst flatMatch = entries.find(\n\t\t(e) => e.name === opts.skillName || e.name.startsWith(`${opts.skillName}.`)\n\t);\n\tif (flatMatch) {\n\t\tawait removeIfExists(path.join(scopeDir, flatMatch.name));\n\t}\n}\n\n/** `true` when `p` exists (following symlinks is irrelevant — `lstat` is enough). */\nasync function pathExists(p: string): Promise<boolean> {\n\ttry {\n\t\tawait fs.lstat(p);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nconst MANIFEST_FILENAME: Record<EntryKind, string> = {\n\tskill: \"SKILL.md\",\n\tagent: \"AGENT.md\",\n};\n\n/**\n * Enumerate all skill/agent entries currently present in a workspace/user\n * scope — both symlinked installs (`mode: \"symlink\"`/`\"junction\"`) AND\n * real, non-symlink directories/files that already carry a valid\n * manifest (`mode: \"copy\"`). Used by the UI's \"Manage installed skills\"\n * view so it can show a real (copy-installed) entry as installed too —\n * this is common for the user (global) scope, where many skills are\n * placed by other tools/installers as plain copies rather than via this\n * package's symlink-only install flow. `repoId` is `\"\"` for `\"copy\"`\n * entries since there's no symlink target to resolve it from; callers\n * match these by name only. Returns an empty array when the scan\n * directory doesn't exist.\n */\nexport async function listLinkedSkills(\n\tworkspaceDir: string,\n\tkind: EntryKind = \"skill\",\n\tscope: LinkScope = \"workspace\"\n): Promise<LinkedSkill[]> {\n\t// No workspace open + workspace scope requested — nothing to scan.\n\t// Guard explicitly rather than falling through to\n\t// `path.join(\"\", \".github\", ...)`, which resolves to a *relative*\n\t// path (anchored at the process's cwd, not a real workspace) and\n\t// could accidentally match an unrelated directory there.\n\tif (scope === \"workspace\" && !workspaceDir) {\n\t\treturn [];\n\t}\n\tconst root =\n\t\tscope === \"user\"\n\t\t\t? path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind])\n\t\t\t: path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind]);\n\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(root, { withFileTypes: true });\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow err;\n\t}\n\n\tconst results: LinkedSkill[] = [];\n\tfor (const entry of entries) {\n\t\tconst entryPath = path.join(root, entry.name);\n\n\t\tif (entry.isSymbolicLink()) {\n\t\t\t// Flat manifest files (e.g. `CSharpExpert.agent.md`) report the\n\t\t\t// bare name, matching what SkillStore/install use elsewhere —\n\t\t\t// otherwise this would look like a different, unrelated entry.\n\t\t\tconst skillName = entry.name.replace(/\\.(agent|skill)\\.md$/, \"\");\n\t\t\tconst rawTarget = await readLinkTarget(entryPath);\n\t\t\tif (!rawTarget) continue; // unreadable\n\t\t\tconst resolvedTarget = resolveTargetPath(entryPath, rawTarget);\n\t\t\t// Skip dangling links — `fs.stat` follows symlinks and throws\n\t\t\t// ENOENT when the target is missing. That's the signal that\n\t\t\t// the link is broken.\n\t\t\tconst targetStat = await fs.stat(resolvedTarget).catch(() => null);\n\t\t\tif (!targetStat) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: extractRepoIdFromTarget(resolvedTarget) ?? \"\",\n\t\t\t\tskillName,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: resolvedTarget,\n\t\t\t\tmode: process.platform === \"win32\" ? \"junction\" : \"symlink\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isDirectory()) {\n\t\t\t// A real (copy-installed) directory — only counts as an\n\t\t\t// installed entry when it actually carries the expected\n\t\t\t// manifest file, so unrelated junk directories in the scope\n\t\t\t// dir (e.g. leftover `.DS_Store`-adjacent folders) aren't\n\t\t\t// misreported as installed skills/agents.\n\t\t\tconst hasManifest = await fs\n\t\t\t\t.access(path.join(entryPath, MANIFEST_FILENAME[kind]))\n\t\t\t\t.then(() => true)\n\t\t\t\t.catch(() => false);\n\t\t\tif (!hasManifest) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isFile() && entry.name.endsWith(`.${kind}.md`)) {\n\t\t\t// A real (copy-installed) flat manifest file, e.g. a plain\n\t\t\t// `CSharpExpert.agent.md` that was placed directly rather\n\t\t\t// than symlinked.\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name.replace(/\\.(agent|skill)\\.md$/, \"\"),\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t}\n\t}\n\treturn results;\n}\n\n// ─────────────────────────────────────────────────────────────────────\n// Internal helpers\n// ─────────────────────────────────────────────────────────────────────\n\n/** Returns `true` when `linkPath` is a symlink/junction pointing at `expectedTarget`. */\nasync function isCorrectLink(linkPath: string, expectedTarget: string): Promise<boolean> {\n\tlet actual: string | undefined;\n\ttry {\n\t\tactual = await readLinkTarget(linkPath);\n\t} catch {\n\t\treturn false;\n\t}\n\tif (!actual) return false;\n\treturn resolveTargetPath(linkPath, actual) === resolveTargetPath(linkPath, expectedTarget);\n}\n\n/** Read a link's target without throwing — returns undefined on ENOENT. */\nasync function readLinkTarget(linkPath: string): Promise<string | undefined> {\n\ttry {\n\t\treturn await fs.readlink(linkPath);\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n\t\tthrow err;\n\t}\n}\n\n/**\n * Resolve a relative symlink target against its parent directory so a\n * relative link reads back as an absolute path comparable to another\n * absolute path.\n */\nfunction resolveTargetPath(linkPath: string, target: string): string {\n\tif (path.isAbsolute(target)) return path.normalize(target);\n\treturn path.normalize(path.resolve(path.dirname(linkPath), target));\n}\n\n/**\n * Parse the `<repoId>` segment back out of a resolved link target of\n * the shape `.../.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Returns `undefined` when the target doesn't match that shape (e.g. a\n * hand-authored local skill under `~/.agents/skills/<name>` with no\n * backing repo).\n */\nfunction extractRepoIdFromTarget(target: string): string | undefined {\n\tconst marker = `${path.sep}repos${path.sep}`;\n\tconst idx = target.indexOf(marker);\n\tif (idx === -1) return undefined;\n\tconst rest = target.slice(idx + marker.length);\n\tconst [repoId] = rest.split(path.sep);\n\treturn repoId || undefined;\n}\n\n/** Best-effort removal of a path. ENOENT is silently ignored. */\nasync function removeIfExists(p: string): Promise<void> {\n\ttry {\n\t\tconst stat = await fs.lstat(p);\n\t\tif (stat.isDirectory() && !stat.isSymbolicLink()) {\n\t\t\tawait fs.rm(p, { recursive: true, force: true });\n\t\t} else {\n\t\t\tawait fs.unlink(p);\n\t\t}\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return;\n\t\tthrow err;\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n"],"mappings":"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","names":["path","stat"]}
1
+ {"version":3,"sources":["../src/skill-linker/index.ts","../src/paths/userHome.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\n\nimport { getHomeDir, getRepoDir } from \"../paths/userHome\";\n\n/**\n * Skill & Agent v2 — Client SkillLinker (M3, path-parity revision)\n *\n * SkillLinker exposes a skill/agent from the user's global repos\n * (`~/.serviceme/repos/<repoId>/{skills,agents}/<name>/`) into either:\n *\n * - a workspace, at `<workspace>/.github/{skills,agents}/<name>` —\n * the same path Copilot/Claude actually scan for `SKILL.md` /\n * `AGENT.md`, so linking here has real effect (see the v1 parity\n * note below); or\n * - the user's global scope, at `~/.agents/{skills,agents}/<name>`,\n * visible to every workspace.\n *\n * **v1 parity note**: an earlier revision linked into a private\n * `<workspace>/.serviceme/skills/<repoId>/<name>` directory. That path\n * is never scanned by the actual tooling, so installs were silently\n * inert. The path was moved to match `.github/skills` (the same\n * location the legacy v1 marketplace pipeline used). Because the link\n * path is now flat (no `<repoId>` segment), only one repo's version of\n * a given `<name>` can be linked into a given scope at a time.\n *\n * Platform behavior (per spec §11.8 — owner explicitly chose throw\n * over silent copy fallback):\n *\n * | platform | default mode | on failure |\n * |----------|--------------|-----------------------------|\n * | macOS | symlink | throw + platform hint |\n * | Linux | symlink | throw + platform hint |\n * | Windows | junction | throw + platform hint |\n *\n * Why junction first on Windows: NTFS junction doesn't require\n * SeCreateSymbolicLinkPrivilege; it works for any user. The limit is\n * the link target must be on a local NTFS volume. OneDrive / network\n * shares sometimes fail; we surface that as a typed error so callers\n * can decide whether to retry.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.6 SkillLinker\n * @see docs/architecture/skill-agent-v2-repo.md §11.8 (throw, not copy)\n */\n\nexport type LinkMode = \"symlink\" | \"junction\" | \"copy\";\n\n/** Kind of artifact being linked. Mirrors `BridgeSkillKind`. */\nexport type EntryKind = \"skill\" | \"agent\";\n\n/**\n * Which scope the link lives in:\n * - `\"workspace\"` (default) → `<workspaceDir>/.github/<kind>/<name>`\n * - `\"user\"` → `~/.agents/<kind>/<name>` (visible to every workspace)\n */\nexport type LinkScope = \"workspace\" | \"user\";\n\nconst ENTRY_SUBDIR: Record<EntryKind, string> = {\n\tskill: \"skills\",\n\tagent: \"agents\",\n};\n\nexport interface InstallSkillOptions {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\t/** Choose link mode explicitly. Defaults to platform-optimal. */\n\tmode?: \"auto\" | LinkMode;\n\t/** \"skill\" (default) or \"agent\". */\n\tkind?: EntryKind;\n\t/** \"workspace\" (default) or \"user\". */\n\tscope?: LinkScope;\n\t/**\n\t * Pre-resolved absolute source path (from `SkillStore`), overriding\n\t * the `<repoRoot>/{skills,agents}/<name>` formula in\n\t * {@link resolveSkillTarget}. Required for repos with non-standard\n\t * layouts — flat root (no `skills/` prefix), nested scope dirs\n\t * (`skills/official/<name>`), or flat single-file agent manifests\n\t * (`agents/<name>.agent.md`) — which the formula can't predict.\n\t * When omitted, falls back to the formula (kept for callers that\n\t * install a hand-authored skill without going through `SkillStore`).\n\t */\n\tsourcePath?: string;\n\t/**\n\t * True when `sourcePath` points at a single manifest FILE (e.g. a\n\t * flat `<name>.agent.md`) rather than a directory. Determines the\n\t * symlink type (file vs dir/junction) and the link's basename (the\n\t * link mirrors the source file's name + extension, since Copilot/\n\t * VS Code scan `.github/agents/<name>.agent.md` as a FILE).\n\t */\n\tsourceIsFile?: boolean;\n}\n\nexport interface InstallSkillResult {\n\tmode: LinkMode;\n\tlinkPath: string;\n\ttargetPath: string;\n}\n\n/** Describes one symlink/junction currently present in a workspace/user scope. */\nexport interface LinkedSkill {\n\t/**\n\t * Repo id the link resolves to, parsed back out of the symlink\n\t * target. Empty string when the target isn't under a recognizable\n\t * `~/.serviceme/repos/<repoId>/...` path.\n\t */\n\trepoId: string;\n\tskillName: string;\n\tlinkPath: string;\n\ttargetPath: string;\n\tmode: LinkMode;\n\t/** Which scope this link was found in — \"workspace\" or \"user\" (global). */\n\tscope: LinkScope;\n}\n\n/**\n * Sentinel error for link failures. Carries the chosen mode + platform\n * hint so the UI can render an actionable message.\n */\nexport class LinkError extends Error {\n\treadonly mode: LinkMode;\n\treadonly target: string;\n\treadonly linkPath: string;\n\treadonly platform: NodeJS.Platform;\n\treadonly cause: unknown;\n\n\tconstructor(opts: {\n\t\tmode: LinkMode;\n\t\ttarget: string;\n\t\tlinkPath: string;\n\t\tplatform: NodeJS.Platform;\n\t\tcause: unknown;\n\t}) {\n\t\tsuper(\n\t\t\t`failed to ${opts.mode} link ${opts.linkPath} → ${opts.target} on ${opts.platform}: ${\n\t\t\t\topts.cause instanceof Error ? opts.cause.message : String(opts.cause)\n\t\t\t}`\n\t\t);\n\t\tthis.name = \"LinkError\";\n\t\tthis.mode = opts.mode;\n\t\tthis.target = opts.target;\n\t\tthis.linkPath = opts.linkPath;\n\t\tthis.platform = opts.platform;\n\t\tthis.cause = opts.cause;\n\t}\n\n\t/** A user-facing hint for the platform that failed. */\n\thint(): string {\n\t\treturn getPlatformHint(this.platform);\n\t}\n}\n\n/** Choose the platform-optimal link mode. */\nexport function pickMode(platform: NodeJS.Platform = process.platform): LinkMode {\n\treturn platform === \"win32\" ? \"junction\" : \"symlink\";\n}\n\n/** A short hint string for the platform where the link failed. */\nexport function getPlatformHint(platform: NodeJS.Platform): string {\n\tif (platform === \"win32\") {\n\t\treturn (\n\t\t\t\"On Windows, enable Developer Mode in Settings → Privacy & Security → \" +\n\t\t\t\"For developers, or run as Administrator. NTFS junction also requires the \" +\n\t\t\t\"target to be on a local NTFS volume (OneDrive / network shares are not supported).\"\n\t\t);\n\t}\n\treturn (\n\t\t\"Symlink creation failed. Check filesystem permissions and that the source \" +\n\t\t\"directory exists. On some filesystems (FAT32, exFAT, network mounts) symlinks \" +\n\t\t\"are not supported.\"\n\t);\n}\n\n/**\n * Resolve the source path inside `~/.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Exported for callers that need to inspect the target without installing.\n */\nexport function resolveSkillTarget(\n\trepoId: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(getRepoDir(repoId), ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the workspace link path — `<workspaceDir>/.github/{skills,agents}/<name>` —\n * the same directory Copilot/Claude actually scan.\n */\nexport function resolveSkillLinkPath(\n\tworkspaceDir: string,\n\tskillName: string,\n\tkind: EntryKind = \"skill\"\n): string {\n\treturn path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind], skillName);\n}\n\n/**\n * Resolve the user-scope link path — `~/.agents/{skills,agents}/<name>` —\n * visible to every workspace.\n */\nexport function resolveUserSkillLinkPath(skillName: string, kind: EntryKind = \"skill\"): string {\n\treturn path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind], skillName);\n}\n\nfunction resolveLinkPath(opts: {\n\tworkspaceDir: string;\n\tskillName: string;\n\tkind: EntryKind;\n\tscope: LinkScope;\n\t/** When set, use this basename instead of the bare `skillName` — for\n\t * file-based sources the link must mirror the source's filename\n\t * (including its extension) to be scannable at its real path. */\n\tlinkBasename?: string;\n}): string {\n\tif (opts.scope === \"workspace\" && !opts.workspaceDir) {\n\t\tthrow new Error(\n\t\t\t\"workspaceDir is required for scope: 'workspace' (pass scope: 'user' for a global install instead).\"\n\t\t);\n\t}\n\tconst base =\n\t\topts.scope === \"user\"\n\t\t\t? resolveUserSkillLinkPath(opts.skillName, opts.kind)\n\t\t\t: resolveSkillLinkPath(opts.workspaceDir, opts.skillName, opts.kind);\n\treturn opts.linkBasename ? path.join(path.dirname(base), opts.linkBasename) : base;\n}\n\n/**\n * Install a skill into a workspace by creating a link at the standard\n * path. Throws `LinkError` on failure (no silent copy fallback per\n * §11.8).\n *\n * Idempotent: if the correct link already exists, returns the existing\n * record without touching the filesystem. If a *wrong* link exists at\n * the target path, it is replaced.\n */\nexport async function installSkillToWorkspace(\n\topts: InstallSkillOptions\n): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst isFile = opts.sourceIsFile ?? false;\n\tconst target = opts.sourcePath ?? resolveSkillTarget(opts.repoId, opts.skillName, kind);\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: isFile ? path.basename(target) : undefined,\n\t});\n\n\t// Sanity-check: the skill source directory must exist before we try\n\t// to link it. Otherwise the link would dangle.\n\tawait fs.access(target).catch(() => {\n\t\tthrow new LinkError({\n\t\t\tmode: \"symlink\", // placeholder — mode not chosen yet\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: new Error(`skill source not found: ${target}`),\n\t\t});\n\t});\n\n\t// If the link already exists and points to the right target, no-op.\n\tif (await isCorrectLink(linkPath, target)) {\n\t\treturn { mode: \"symlink\", linkPath, targetPath: target };\n\t}\n\t// Otherwise, remove any stale entry at the path.\n\tawait removeIfExists(linkPath);\n\n\t// Junctions are directory-only — a file source can never use one,\n\t// regardless of platform default. Fall back to a real symlink.\n\tconst requestedMode = opts.mode === undefined || opts.mode === \"auto\" ? pickMode() : opts.mode;\n\tconst mode = isFile && requestedMode === \"junction\" ? \"symlink\" : requestedMode;\n\n\t// Ensure the parent directory exists — fs.symlink does NOT create\n\t// missing parents. The path is `<workspace>/.serviceme/skills/<repoId>/`,\n\t// which is several levels deep on a fresh workspace.\n\tawait fs.mkdir(path.dirname(linkPath), { recursive: true });\n\n\ttry {\n\t\tif (mode === \"junction\") {\n\t\t\t// fs.symlink with type \"junction\" works on Windows for dirs.\n\t\t\tawait fs.symlink(target, linkPath, \"junction\");\n\t\t} else if (mode === \"symlink\") {\n\t\t\t// Windows requires an explicit \"file\" type for file symlinks;\n\t\t\t// POSIX ignores the type argument either way.\n\t\t\tawait fs.symlink(target, linkPath, isFile ? \"file\" : \"dir\");\n\t\t} else {\n\t\t\t// `copy` mode is intentionally NOT implemented for v1 — see\n\t\t\t// spec §11.8 (\"throw, not copy fallback\"). Reaching this branch\n\t\t\t// means a caller explicitly passed `mode: 'copy'`; we treat\n\t\t\t// it as a configuration error rather than silently dropping\n\t\t\t// to copy semantics.\n\t\t\tthrow new LinkError({\n\t\t\t\tmode: \"copy\",\n\t\t\t\ttarget,\n\t\t\t\tlinkPath,\n\t\t\t\tplatform: process.platform,\n\t\t\t\tcause: new Error(\n\t\t\t\t\t\"copy mode is not supported in v1 — see spec §11.8 (throw, not copy fallback)\"\n\t\t\t\t),\n\t\t\t});\n\t\t}\n\t} catch (err) {\n\t\tif (err instanceof LinkError) throw err;\n\t\tthrow new LinkError({\n\t\t\tmode,\n\t\t\ttarget,\n\t\t\tlinkPath,\n\t\t\tplatform: process.platform,\n\t\t\tcause: err,\n\t\t});\n\t}\n\n\treturn { mode, linkPath, targetPath: target };\n}\n\n/**\n * Convert an existing, real (non-symlink) skill/agent directory at the\n * standard scan path into a symlink pointing at the matching global\n * repo entry. Refuses to run when there's nothing real to convert —\n * callers should only surface this action when a marketplace/scope\n * state check already confirmed a non-symlink install is present.\n */\nexport async function convertToSymlink(opts: InstallSkillOptions): Promise<InstallSkillResult> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t\tlinkBasename: opts.sourceIsFile && opts.sourcePath ? path.basename(opts.sourcePath) : undefined,\n\t});\n\n\tconst stat = await fs.lstat(linkPath).catch(() => null);\n\tif (!stat) {\n\t\tthrow new Error(`Nothing installed at ${linkPath} to convert.`);\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(`${linkPath} is already a symlink.`);\n\t}\n\n\treturn installSkillToWorkspace(opts);\n}\n\n/**\n * Remove a skill/agent link from a workspace/user scope. Idempotent:\n * succeeds when the link does not exist (no error).\n *\n * Tries the bare `<name>` path first (directory-based entries), then\n * falls back to scanning the scope dir for a `<name>.<ext>` file (flat\n * manifest entries, e.g. `CSharpExpert.agent.md`) — the caller doesn't\n * necessarily still have the source repo entry to know which shape was\n * installed (e.g. the repo may have been removed since).\n */\nexport async function uninstallSkillFromWorkspace(opts: {\n\trepoId: string;\n\tskillName: string;\n\tworkspaceDir: string;\n\tkind?: EntryKind;\n\tscope?: LinkScope;\n}): Promise<void> {\n\tconst kind = opts.kind ?? \"skill\";\n\tconst scope = opts.scope ?? \"workspace\";\n\tconst linkPath = resolveLinkPath({\n\t\tworkspaceDir: opts.workspaceDir,\n\t\tskillName: opts.skillName,\n\t\tkind,\n\t\tscope,\n\t});\n\tif (await pathExists(linkPath)) {\n\t\tawait removeIfExists(linkPath);\n\t\treturn;\n\t}\n\n\tconst scopeDir = path.dirname(linkPath);\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(scopeDir, { withFileTypes: true });\n\t} catch {\n\t\treturn; // nothing installed at all — idempotent no-op\n\t}\n\tconst flatMatch = entries.find(\n\t\t(e) => e.name === opts.skillName || e.name.startsWith(`${opts.skillName}.`)\n\t);\n\tif (flatMatch) {\n\t\tawait removeIfExists(path.join(scopeDir, flatMatch.name));\n\t}\n}\n\n/** `true` when `p` exists (following symlinks is irrelevant — `lstat` is enough). */\nasync function pathExists(p: string): Promise<boolean> {\n\ttry {\n\t\tawait fs.lstat(p);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nconst MANIFEST_FILENAME: Record<EntryKind, string> = {\n\tskill: \"SKILL.md\",\n\tagent: \"AGENT.md\",\n};\n\n/**\n * Enumerate all skill/agent entries currently present in a workspace/user\n * scope — both symlinked installs (`mode: \"symlink\"`/`\"junction\"`) AND\n * real, non-symlink directories/files that already carry a valid\n * manifest (`mode: \"copy\"`). Used by the UI's \"Manage installed skills\"\n * view so it can show a real (copy-installed) entry as installed too —\n * this is common for the user (global) scope, where many skills are\n * placed by other tools/installers as plain copies rather than via this\n * package's symlink-only install flow. `repoId` is `\"\"` for `\"copy\"`\n * entries since there's no symlink target to resolve it from; callers\n * match these by name only. Returns an empty array when the scan\n * directory doesn't exist.\n */\nexport async function listLinkedSkills(\n\tworkspaceDir: string,\n\tkind: EntryKind = \"skill\",\n\tscope: LinkScope = \"workspace\"\n): Promise<LinkedSkill[]> {\n\t// No workspace open + workspace scope requested — nothing to scan.\n\t// Guard explicitly rather than falling through to\n\t// `path.join(\"\", \".github\", ...)`, which resolves to a *relative*\n\t// path (anchored at the process's cwd, not a real workspace) and\n\t// could accidentally match an unrelated directory there.\n\tif (scope === \"workspace\" && !workspaceDir) {\n\t\treturn [];\n\t}\n\tconst root =\n\t\tscope === \"user\"\n\t\t\t? path.join(getHomeDir(), \".agents\", ENTRY_SUBDIR[kind])\n\t\t\t: path.join(workspaceDir, \".github\", ENTRY_SUBDIR[kind]);\n\n\tlet entries: import(\"node:fs\").Dirent[];\n\ttry {\n\t\tentries = await fs.readdir(root, { withFileTypes: true });\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n\t\tthrow err;\n\t}\n\n\tconst results: LinkedSkill[] = [];\n\tfor (const entry of entries) {\n\t\tconst entryPath = path.join(root, entry.name);\n\n\t\tif (entry.isSymbolicLink()) {\n\t\t\t// Flat manifest files (e.g. `CSharpExpert.agent.md`) report the\n\t\t\t// bare name, matching what SkillStore/install use elsewhere —\n\t\t\t// otherwise this would look like a different, unrelated entry.\n\t\t\tconst skillName = entry.name.replace(/\\.(agent|skill)\\.md$/, \"\");\n\t\t\tconst rawTarget = await readLinkTarget(entryPath);\n\t\t\tif (!rawTarget) continue; // unreadable\n\t\t\tconst resolvedTarget = resolveTargetPath(entryPath, rawTarget);\n\t\t\t// Skip dangling links — `fs.stat` follows symlinks and throws\n\t\t\t// ENOENT when the target is missing. That's the signal that\n\t\t\t// the link is broken.\n\t\t\tconst targetStat = await fs.stat(resolvedTarget).catch(() => null);\n\t\t\tif (!targetStat) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: extractRepoIdFromTarget(resolvedTarget) ?? \"\",\n\t\t\t\tskillName,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: resolvedTarget,\n\t\t\t\tmode: process.platform === \"win32\" ? \"junction\" : \"symlink\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isDirectory()) {\n\t\t\t// A real (copy-installed) directory — only counts as an\n\t\t\t// installed entry when it actually carries the expected\n\t\t\t// manifest file, so unrelated junk directories in the scope\n\t\t\t// dir (e.g. leftover `.DS_Store`-adjacent folders) aren't\n\t\t\t// misreported as installed skills/agents.\n\t\t\tconst hasManifest = await fs\n\t\t\t\t.access(path.join(entryPath, MANIFEST_FILENAME[kind]))\n\t\t\t\t.then(() => true)\n\t\t\t\t.catch(() => false);\n\t\t\tif (!hasManifest) continue;\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name,\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (entry.isFile() && entry.name.endsWith(`.${kind}.md`)) {\n\t\t\t// A real (copy-installed) flat manifest file, e.g. a plain\n\t\t\t// `CSharpExpert.agent.md` that was placed directly rather\n\t\t\t// than symlinked.\n\t\t\tresults.push({\n\t\t\t\trepoId: \"\",\n\t\t\t\tskillName: entry.name.replace(/\\.(agent|skill)\\.md$/, \"\"),\n\t\t\t\tlinkPath: entryPath,\n\t\t\t\ttargetPath: entryPath,\n\t\t\t\tmode: \"copy\",\n\t\t\t\tscope,\n\t\t\t});\n\t\t}\n\t}\n\treturn results;\n}\n\n// ─────────────────────────────────────────────────────────────────────\n// Internal helpers\n// ─────────────────────────────────────────────────────────────────────\n\n/** Returns `true` when `linkPath` is a symlink/junction pointing at `expectedTarget`. */\nasync function isCorrectLink(linkPath: string, expectedTarget: string): Promise<boolean> {\n\tlet actual: string | undefined;\n\ttry {\n\t\tactual = await readLinkTarget(linkPath);\n\t} catch {\n\t\treturn false;\n\t}\n\tif (!actual) return false;\n\treturn resolveTargetPath(linkPath, actual) === resolveTargetPath(linkPath, expectedTarget);\n}\n\n/** Read a link's target without throwing — returns undefined on ENOENT. */\nasync function readLinkTarget(linkPath: string): Promise<string | undefined> {\n\ttry {\n\t\treturn await fs.readlink(linkPath);\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n\t\tthrow err;\n\t}\n}\n\n/**\n * Resolve a relative symlink target against its parent directory so a\n * relative link reads back as an absolute path comparable to another\n * absolute path.\n */\nfunction resolveTargetPath(linkPath: string, target: string): string {\n\tif (path.isAbsolute(target)) return path.normalize(target);\n\treturn path.normalize(path.resolve(path.dirname(linkPath), target));\n}\n\n/**\n * Parse the `<repoId>` segment back out of a resolved link target of\n * the shape `.../.serviceme/repos/<repoId>/{skills,agents}/<name>`.\n * Returns `undefined` when the target doesn't match that shape (e.g. a\n * hand-authored local skill under `~/.agents/skills/<name>` with no\n * backing repo).\n */\nfunction extractRepoIdFromTarget(target: string): string | undefined {\n\tconst marker = `${path.sep}repos${path.sep}`;\n\tconst idx = target.indexOf(marker);\n\tif (idx === -1) return undefined;\n\tconst rest = target.slice(idx + marker.length);\n\tconst [repoId] = rest.split(path.sep);\n\treturn repoId || undefined;\n}\n\n/** Best-effort removal of a path. ENOENT is silently ignored. */\nasync function removeIfExists(p: string): Promise<void> {\n\ttry {\n\t\tconst stat = await fs.lstat(p);\n\t\tif (stat.isDirectory() && !stat.isSymbolicLink()) {\n\t\t\tawait fs.rm(p, { recursive: true, force: true });\n\t\t} else {\n\t\t\tawait fs.unlink(p);\n\t\t}\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") return;\n\t\tthrow err;\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/** rev.20 — r7 BYOM Server Proxy toggle self-state (read by ext + CLI + server). */\nexport const SERVER_PROXY_GLOBAL_FILENAME = \"server-proxy.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── r7 BYOM Server Proxy (rev.20) ─────────────────────────────────────────\n\n/**\n * `~/.serviceme/server-proxy.json` — r7 BYOM Server Proxy toggle\n * self-state. Written by the extension's `ProxyConfigService`, readable\n * by the CLI / Server so they can decide whether to route traffic\n * through the corporate proxy without booting VS Code.\n *\n * Lives under user-level `~/.serviceme/` (not under a workspace) because\n * the toggle is a *user preference* — the same human enabling it on\n * machine A should be able to rely on it on machine B after sync, not\n * have it disappear when they switch repos.\n */\nexport function getServerProxyGlobalPath(): string {\n\treturn path.join(getServicemeHome(), SERVER_PROXY_GLOBAL_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n"],"mappings":"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","names":["path","stat"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/submit/index.ts","../src/paths/userHome.ts","../src/submit/types.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport type { GitClient } from \"../git-client\";\nimport { getRepoDir } from \"../paths/userHome\";\nimport type { SkillFile } from \"../skill-store/types\";\nimport type { SubmitValidationRequest, SubmitValidationResponse } from \"./types\";\nimport { SubmitError } from \"./types\";\n\n// Re-export so consumers (bridge handlers, CLI commands) can detect\n// SubmitError via `instanceof` without reaching into the internal\n// `./types` module.\nexport { SubmitError } from \"./types\";\n\n/**\n * Skill & Agent v2 — SubmitClient (M4)\n *\n * SubmitClient orchestrates the \"validate then push\" flow described\n * in docs/architecture/skill-agent-v2-repo.md §5.7:\n *\n * 1. **validate** — POST the candidate files to the server's\n * `/api/v1/skills/validate` endpoint (5 deny reasons: repo_not_\n * writable, file_too_large, path_traversal, invalid_frontmatter,\n * name_conflict).\n * 2. **write** — materialize the files into the local repo clone at\n * `~/.serviceme/repos/<repoId>/skills/<name>/` (or `agents/`).\n * 3. **commit** — `git add . && git commit -m \"feat(skills): add <name>\"`.\n * 4. **push** — `git push origin <branch>` via the server proxy.\n *\n * The client is intentionally thin: it does NOT do its own validation\n * (the server is the gate), and it does NOT cache anything across\n * calls. The single source of truth for \"is this submission allowed?\"\n * is the server's validate endpoint.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.7 SubmitClient\n */\n\nexport interface SubmitOptions {\n\t/** Override the server's validate URL. Defaults to `http://localhost:3000/api/v1`. */\n\tserverBaseUrl?: string;\n\t/** Override the branch to push. Defaults to the repo's `branch` field. */\n\tbranch?: string;\n\t/** Skip the actual push (for tests + dry-runs). When true, step 4 returns a synthetic PushResult. */\n\tskipPush?: boolean;\n}\n\nexport interface SubmitResult {\n\trepoId: string;\n\tskillName: string;\n\tcommitSha: string;\n\tpushedRef?: string;\n\tpushedSha?: string;\n}\n\nexport interface SubmitClientOptions {\n\tgitClient: GitClient;\n\t/** Lookup the default branch for a repo (config-driven). */\n\tgetRepoBranch?: (repoId: string) => string | undefined;\n\t/** HTTP fetch impl (defaults to the global `fetch`). */\n\tfetcher?: typeof fetch;\n\t/** Default server base URL when no override is supplied. */\n\tdefaultServerBaseUrl?: string;\n}\n\nexport class SubmitClient {\n\tprivate readonly git: GitClient;\n\tprivate readonly getRepoBranch: (repoId: string) => string | undefined;\n\tprivate readonly fetcher: typeof fetch;\n\tprivate readonly defaultServerBaseUrl: string;\n\n\tconstructor(opts: SubmitClientOptions) {\n\t\tthis.git = opts.gitClient;\n\t\tthis.getRepoBranch = opts.getRepoBranch ?? (() => undefined);\n\t\tthis.fetcher = opts.fetcher ?? (globalThis.fetch as typeof fetch);\n\t\tthis.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? \"http://localhost:3000\";\n\t}\n\n\t/**\n\t * Validate-only path. Useful for the UI's \"Save Draft\" flow which\n\t * wants to surface validation errors without committing or pushing.\n\t */\n\tasync validate(req: SubmitValidationRequest): Promise<SubmitValidationResponse> {\n\t\tconst url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;\n\t\tconst res = await this.fetcher(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify(req),\n\t\t});\n\t\tif (!res.ok) {\n\t\t\tthrow new SubmitError(\"network_error\", `validate request failed: HTTP ${res.status}`);\n\t\t}\n\t\treturn (await res.json()) as SubmitValidationResponse;\n\t}\n\n\t/**\n\t * Full submit pipeline. Throws `SubmitError` on:\n\t * - validate deny (reason echoed)\n\t * - network failure (network_error)\n\t * - local write failure\n\t * - commit/push failure\n\t */\n\tasync submit(\n\t\trepoId: string,\n\t\tskillName: string,\n\t\tfiles: SkillFile[],\n\t\topts: SubmitOptions = {}\n\t): Promise<SubmitResult> {\n\t\t// (1) Validate\n\t\tconst v = await this.validate({ repoId, skillName, files });\n\t\tif (!v.allow) {\n\t\t\tthrow new SubmitError(v.reason ?? \"unknown\", v.detail ?? \"validation denied\");\n\t\t}\n\n\t\t// (2) Write files into the local repo clone\n\t\tconst localRepoPath = getRepoDir(repoId);\n\t\tconst targetDir = path.join(localRepoPath, \"skills\", skillName);\n\t\tawait fs.mkdir(targetDir, { recursive: true });\n\t\tfor (const f of files) {\n\t\t\tconst full = path.join(targetDir, f.path);\n\t\t\tawait fs.mkdir(path.dirname(full), { recursive: true });\n\t\t\tconst tmp = `${full}.${process.pid}.${Date.now()}.tmp`;\n\t\t\tawait fs.writeFile(tmp, f.content, \"utf8\");\n\t\t\tawait fs.rename(tmp, full);\n\t\t}\n\n\t\t// (3) Commit (convention: `feat(skills): add <name>`)\n\t\tconst commitMessage = `feat(skills): add ${skillName}`;\n\t\tconst { commitSha } = await this.git.commit(localRepoPath, commitMessage);\n\n\t\t// (4) Push\n\t\tlet pushedRef: string | undefined;\n\t\tlet pushedSha: string | undefined;\n\t\tif (!opts.skipPush) {\n\t\t\tconst branch = opts.branch ?? this.getRepoBranch(repoId) ?? \"main\";\n\t\t\tconst pushResult = await this.git.push(repoId, localRepoPath, branch);\n\t\t\tpushedRef = pushResult.ref;\n\t\t\tpushedSha = pushResult.commitSha;\n\t\t}\n\n\t\treturn {\n\t\t\trepoId,\n\t\t\tskillName,\n\t\t\tcommitSha,\n\t\t\tpushedRef,\n\t\t\tpushedSha,\n\t\t};\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n","/**\n * Skill & Agent v2 — SubmitClient Types (M4)\n *\n * Mirrors the server's POST /api/v1/skills/validate contract (M2\n * SubmitApi). Defined here as a separate types file so the test\n * fixtures + the client can both import without circular deps.\n */\n\n/** Reasons the server may deny a submission. */\nexport type DenyReason =\n\t| \"repo_not_writable\"\n\t| \"file_too_large\"\n\t| \"path_traversal\"\n\t| \"invalid_frontmatter\"\n\t| \"name_conflict\"\n\t/** Local-client-side errors that don't come from the server. */\n\t| \"network_error\"\n\t| \"write_error\"\n\t| \"commit_error\"\n\t| \"push_error\"\n\t| \"unknown\";\n\n/** Request payload. Identical to the server's SubmitValidationRequest. */\nexport interface SubmitValidationRequest {\n\trepoId: string;\n\tskillName: string;\n\tfiles: Array<{ path: string; content: string }>;\n}\n\n/** Response payload. Identical to the server's SubmitValidationResponse. */\nexport interface SubmitValidationResponse {\n\tallow: boolean;\n\treason?: DenyReason;\n\tdetail?: string;\n}\n\n/** Sentinel error thrown by SubmitClient.submit() / validate(). */\nexport class SubmitError extends Error {\n\treadonly reason: DenyReason;\n\treadonly detail: string;\n\treadonly status?: number;\n\n\tconstructor(reason: DenyReason, detail: string, options?: { status?: number }) {\n\t\tsuper(`submit failed (${reason}): ${detail}`);\n\t\tthis.name = \"SubmitError\";\n\t\tthis.reason = reason;\n\t\tthis.detail = detail;\n\t\tthis.status = options?.status;\n\t}\n}\n"],"mappings":"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","names":["path"]}
1
+ {"version":3,"sources":["../src/submit/index.ts","../src/paths/userHome.ts","../src/submit/types.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport type { GitClient } from \"../git-client\";\nimport { getRepoDir } from \"../paths/userHome\";\nimport type { SkillFile } from \"../skill-store/types\";\nimport type { SubmitValidationRequest, SubmitValidationResponse } from \"./types\";\nimport { SubmitError } from \"./types\";\n\n// Re-export so consumers (bridge handlers, CLI commands) can detect\n// SubmitError via `instanceof` without reaching into the internal\n// `./types` module.\nexport { SubmitError } from \"./types\";\n\n/**\n * Skill & Agent v2 — SubmitClient (M4)\n *\n * SubmitClient orchestrates the \"validate then push\" flow described\n * in docs/architecture/skill-agent-v2-repo.md §5.7:\n *\n * 1. **validate** — POST the candidate files to the server's\n * `/api/v1/skills/validate` endpoint (5 deny reasons: repo_not_\n * writable, file_too_large, path_traversal, invalid_frontmatter,\n * name_conflict).\n * 2. **write** — materialize the files into the local repo clone at\n * `~/.serviceme/repos/<repoId>/skills/<name>/` (or `agents/`).\n * 3. **commit** — `git add . && git commit -m \"feat(skills): add <name>\"`.\n * 4. **push** — `git push origin <branch>` via the server proxy.\n *\n * The client is intentionally thin: it does NOT do its own validation\n * (the server is the gate), and it does NOT cache anything across\n * calls. The single source of truth for \"is this submission allowed?\"\n * is the server's validate endpoint.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.7 SubmitClient\n */\n\nexport interface SubmitOptions {\n\t/** Override the server's validate URL. Defaults to `http://localhost:3000/api/v1`. */\n\tserverBaseUrl?: string;\n\t/** Override the branch to push. Defaults to the repo's `branch` field. */\n\tbranch?: string;\n\t/** Skip the actual push (for tests + dry-runs). When true, step 4 returns a synthetic PushResult. */\n\tskipPush?: boolean;\n}\n\nexport interface SubmitResult {\n\trepoId: string;\n\tskillName: string;\n\tcommitSha: string;\n\tpushedRef?: string;\n\tpushedSha?: string;\n}\n\nexport interface SubmitClientOptions {\n\tgitClient: GitClient;\n\t/** Lookup the default branch for a repo (config-driven). */\n\tgetRepoBranch?: (repoId: string) => string | undefined;\n\t/** HTTP fetch impl (defaults to the global `fetch`). */\n\tfetcher?: typeof fetch;\n\t/** Default server base URL when no override is supplied. */\n\tdefaultServerBaseUrl?: string;\n}\n\nexport class SubmitClient {\n\tprivate readonly git: GitClient;\n\tprivate readonly getRepoBranch: (repoId: string) => string | undefined;\n\tprivate readonly fetcher: typeof fetch;\n\tprivate readonly defaultServerBaseUrl: string;\n\n\tconstructor(opts: SubmitClientOptions) {\n\t\tthis.git = opts.gitClient;\n\t\tthis.getRepoBranch = opts.getRepoBranch ?? (() => undefined);\n\t\tthis.fetcher = opts.fetcher ?? (globalThis.fetch as typeof fetch);\n\t\tthis.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? \"http://localhost:3000\";\n\t}\n\n\t/**\n\t * Validate-only path. Useful for the UI's \"Save Draft\" flow which\n\t * wants to surface validation errors without committing or pushing.\n\t */\n\tasync validate(req: SubmitValidationRequest): Promise<SubmitValidationResponse> {\n\t\tconst url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;\n\t\tconst res = await this.fetcher(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify(req),\n\t\t});\n\t\tif (!res.ok) {\n\t\t\tthrow new SubmitError(\"network_error\", `validate request failed: HTTP ${res.status}`);\n\t\t}\n\t\treturn (await res.json()) as SubmitValidationResponse;\n\t}\n\n\t/**\n\t * Full submit pipeline. Throws `SubmitError` on:\n\t * - validate deny (reason echoed)\n\t * - network failure (network_error)\n\t * - local write failure\n\t * - commit/push failure\n\t */\n\tasync submit(\n\t\trepoId: string,\n\t\tskillName: string,\n\t\tfiles: SkillFile[],\n\t\topts: SubmitOptions = {}\n\t): Promise<SubmitResult> {\n\t\t// (1) Validate\n\t\tconst v = await this.validate({ repoId, skillName, files });\n\t\tif (!v.allow) {\n\t\t\tthrow new SubmitError(v.reason ?? \"unknown\", v.detail ?? \"validation denied\");\n\t\t}\n\n\t\t// (2) Write files into the local repo clone\n\t\tconst localRepoPath = getRepoDir(repoId);\n\t\tconst targetDir = path.join(localRepoPath, \"skills\", skillName);\n\t\tawait fs.mkdir(targetDir, { recursive: true });\n\t\tfor (const f of files) {\n\t\t\tconst full = path.join(targetDir, f.path);\n\t\t\tawait fs.mkdir(path.dirname(full), { recursive: true });\n\t\t\tconst tmp = `${full}.${process.pid}.${Date.now()}.tmp`;\n\t\t\tawait fs.writeFile(tmp, f.content, \"utf8\");\n\t\t\tawait fs.rename(tmp, full);\n\t\t}\n\n\t\t// (3) Commit (convention: `feat(skills): add <name>`)\n\t\tconst commitMessage = `feat(skills): add ${skillName}`;\n\t\tconst { commitSha } = await this.git.commit(localRepoPath, commitMessage);\n\n\t\t// (4) Push\n\t\tlet pushedRef: string | undefined;\n\t\tlet pushedSha: string | undefined;\n\t\tif (!opts.skipPush) {\n\t\t\tconst branch = opts.branch ?? this.getRepoBranch(repoId) ?? \"main\";\n\t\t\tconst pushResult = await this.git.push(repoId, localRepoPath, branch);\n\t\t\tpushedRef = pushResult.ref;\n\t\t\tpushedSha = pushResult.commitSha;\n\t\t}\n\n\t\treturn {\n\t\t\trepoId,\n\t\t\tskillName,\n\t\t\tcommitSha,\n\t\t\tpushedRef,\n\t\t\tpushedSha,\n\t\t};\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/** rev.20 — r7 BYOM Server Proxy toggle self-state (read by ext + CLI + server). */\nexport const SERVER_PROXY_GLOBAL_FILENAME = \"server-proxy.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── r7 BYOM Server Proxy (rev.20) ─────────────────────────────────────────\n\n/**\n * `~/.serviceme/server-proxy.json` — r7 BYOM Server Proxy toggle\n * self-state. Written by the extension's `ProxyConfigService`, readable\n * by the CLI / Server so they can decide whether to route traffic\n * through the corporate proxy without booting VS Code.\n *\n * Lives under user-level `~/.serviceme/` (not under a workspace) because\n * the toggle is a *user preference* — the same human enabling it on\n * machine A should be able to rely on it on machine B after sync, not\n * have it disappear when they switch repos.\n */\nexport function getServerProxyGlobalPath(): string {\n\treturn path.join(getServicemeHome(), SERVER_PROXY_GLOBAL_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n","/**\n * Skill & Agent v2 — SubmitClient Types (M4)\n *\n * Mirrors the server's POST /api/v1/skills/validate contract (M2\n * SubmitApi). Defined here as a separate types file so the test\n * fixtures + the client can both import without circular deps.\n */\n\n/** Reasons the server may deny a submission. */\nexport type DenyReason =\n\t| \"repo_not_writable\"\n\t| \"file_too_large\"\n\t| \"path_traversal\"\n\t| \"invalid_frontmatter\"\n\t| \"name_conflict\"\n\t/** Local-client-side errors that don't come from the server. */\n\t| \"network_error\"\n\t| \"write_error\"\n\t| \"commit_error\"\n\t| \"push_error\"\n\t| \"unknown\";\n\n/** Request payload. Identical to the server's SubmitValidationRequest. */\nexport interface SubmitValidationRequest {\n\trepoId: string;\n\tskillName: string;\n\tfiles: Array<{ path: string; content: string }>;\n}\n\n/** Response payload. Identical to the server's SubmitValidationResponse. */\nexport interface SubmitValidationResponse {\n\tallow: boolean;\n\treason?: DenyReason;\n\tdetail?: string;\n}\n\n/** Sentinel error thrown by SubmitClient.submit() / validate(). */\nexport class SubmitError extends Error {\n\treadonly reason: DenyReason;\n\treadonly detail: string;\n\treadonly status?: number;\n\n\tconstructor(reason: DenyReason, detail: string, options?: { status?: number }) {\n\t\tsuper(`submit failed (${reason}): ${detail}`);\n\t\tthis.name = \"SubmitError\";\n\t\tthis.reason = reason;\n\t\tthis.detail = detail;\n\t\tthis.status = options?.status;\n\t}\n}\n"],"mappings":"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","names":["path"]}