(srcRoot: string, originalSource: string, opts: { force?: boolean })
| 158 | } |
| 159 | |
| 160 | async function copyInto(srcRoot: string, originalSource: string, opts: { force?: boolean }): Promise<InstallResult> { |
| 161 | const raw = await fs.readFile(path.join(srcRoot, 'SKILL.md'), 'utf-8'); |
| 162 | // Parse to get the canonical name. If frontmatter has `name:`, prefer that; |
| 163 | // otherwise fall back to the directory name. |
| 164 | const fmName = extractFrontmatterName(raw); |
| 165 | const dirName = path.basename(srcRoot); |
| 166 | const name = (fmName ?? dirName).toLowerCase(); |
| 167 | if (!NAME_RE.test(name)) { |
| 168 | throw new Error(`Skill name "${name}" is invalid. Names must match /^[a-z][a-z0-9-]*$/.`); |
| 169 | } |
| 170 | // Validate it parses cleanly before we commit |
| 171 | const parsed = parseSkill(raw, name, srcRoot, 'user'); |
| 172 | if (!parsed) throw new Error('SKILL.md is missing a `description:` field.'); |
| 173 | |
| 174 | // Security scan BEFORE anything touches disk. A skill is instructions the agent |
| 175 | // reads and acts on, so a malicious one is close to running untrusted code: |
| 176 | // prompt injection, secret exfiltration, destructive shell, hidden unicode. |
| 177 | // `dangerous` findings hard-block even with --force; `suspicious` can be |
| 178 | // overridden with --force after the user reviews the report. |
| 179 | const scanText = await gatherSkillText(srcRoot); |
| 180 | const scan = scanSkillContent(scanText); |
| 181 | if (scan.severity !== 'clean') { |
| 182 | const report = formatScanReport(scan, name); |
| 183 | if (scan.severity === 'dangerous') { |
| 184 | throw new Error( |
| 185 | `${report}\n\nInstall blocked. Dangerous findings cannot be overridden with --force. ` + |
| 186 | `Inspect the skill source manually before trusting it.`, |
| 187 | ); |
| 188 | } |
| 189 | if (!opts.force) { |
| 190 | throw new Error( |
| 191 | `${report}\n\nInstall paused. Review the skill, then re-run with --force to install anyway.`, |
| 192 | ); |
| 193 | } |
| 194 | // suspicious + force → proceed, but the report was shown above by the caller path. |
| 195 | } |
| 196 | |
| 197 | const target = path.join(userSkillsDir(), name); |
| 198 | try { |
| 199 | await fs.stat(target); |
| 200 | if (!opts.force) { |
| 201 | throw new Error(`Skill "${name}" is already installed at ${target}. Pass --force to overwrite, or use \`qodex skill remove ${name}\` first.`); |
| 202 | } |
| 203 | await fs.rm(target, { recursive: true, force: true }); |
| 204 | } catch (e: any) { |
| 205 | // The ONLY case we swallow is a genuine "not installed yet": fs.stat threw |
| 206 | // ENOENT because the target dir doesn't exist. Everything else must |
| 207 | // propagate — the already-installed sentinel (so the caller sees it), and |
| 208 | // any real IO error including a failed fs.rm during --force overwrite. |
| 209 | // Swallowing an rm failure here would let us copy over a half-removed dir |
| 210 | // and produce a corrupt skill. |
| 211 | if (e?.code !== 'ENOENT') throw e; |
| 212 | } |
| 213 | await fs.mkdir(target, { recursive: true }); |
| 214 | await copyDir(srcRoot, target); |
| 215 | |
| 216 | // Stamp provenance into SKILL.md if not already present |
| 217 | await stampSource(path.join(target, 'SKILL.md'), originalSource); |
no test coverage detected