feat: 技能支持多文件导入,新增/编辑改为单一 SKILL.md 编辑器 - #190
Conversation
| export function scratchDir(opts?: SkillPathsOpts): string { | ||
| return path.join(opts?.molioHome ?? defaultMolioHome(), 'scratch'); | ||
| } |
There was a problem hiding this comment.
scratchDir returns one fixed directory (~/.molio/scratch) that prefill.ts uses as the cwd for throwaway Claude runs (see ensureScratchCwd / createRun({ cwd })). Two concurrent "Save as skill" prefill requests will therefore share the exact same working directory. If the spawned process writes any per-run state into its cwd (session/cache/temp files with predictable names), concurrent runs can clobber or read each other's files, producing corrupted prefill output or intermittent failures. Consider giving each run a unique subdir (e.g. path.join(scratchDir(opts), randomUUID())) so concurrent runs are isolated, and clean it up on settle.
| const existing = getSkill(db, seed.id); | ||
| if (existing) { | ||
| refreshMeta(db, seed.id, seed.name, seed.description); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Bug (medium): When a core skill already exists, refreshMeta only updates name/description but never refreshes instructions. Since core skills are explicitly "hidden + always-on + not configurable" — users cannot edit their instructions — the hardcoded CORE_SKILLS_SEEDS prompts are the sole source of truth. Yet they are frozen at first-seed time: once the row exists, any prompt improvement or bug-fix in a future app version will never reach existing installations.
This is inconsistent with the stated design: name/description are refreshed every startup (so the UI stays in sync), but the actual instructions driving agent behavior are silently locked to whatever was seeded on first run.
Consider also refreshing instructions (and rewriting the on-disk SKILL.md via writeSkillMd + re-syncing vaults) for core: true skills during seeding, since there is no user-editable state to preserve for them.
|
|
||
| // 3. Remove the legacy global ~/.claude/skills/molio--* sync left over from the | ||
| // pre-per-vault design (idempotent; safe to run every startup). | ||
| cleanupLegacyGlobalSync(); |
There was a problem hiding this comment.
Bug (missing error handling at system boundary): Unlike initSkillLibrary (internal try/catch) and reconcileAllVaults (per-vault try/catch inside reconcileVault), cleanupLegacyGlobalSync has no error protection — neither internally nor at this call site. Internally it calls reconcileSync([], …) whose orphan-cleanup loop does fs.rmSync(…) on each molio--* dir without a try/catch. If any rmSync throws (e.g. EACCES on a root-owned dir, or a file locked on Windows), the exception propagates here and crashes the daemon before startServer() is ever reached.
This contradicts the comment ("idempotent; safe to run every startup") and the best-effort design used everywhere else in this startup sequence. Wrap the call (or add a try/catch inside cleanupLegacyGlobalSync) so a cleanup failure degrades to a warning rather than preventing startup.
Suggestion:
| cleanupLegacyGlobalSync(); | |
| try { | |
| cleanupLegacyGlobalSync(); | |
| } catch (err) { | |
| console.warn('[skills] Legacy global sync cleanup failed (non-fatal):', err instanceof Error ? err.message : err); | |
| } |
| const src = resolveSource(folderPath.trim()); | ||
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
There was a problem hiding this comment.
Bug (missing error handling at fs boundary): fs.readFileSync can throw raw system errors (EISDIR if SKILL.md is actually a directory, EACCES for permission issues, or ENOENT from a TOCTOU race between existsSync in resolveSource and this read). These propagate as unhandled system errors through the route's catch-all, returning a generic 500 instead of a meaningful SkillImportError.
Notably, resolveSource only checks fs.existsSync(skillMd) for directories — it does not verify that SKILL.md is a file (not a subdirectory). A directory named SKILL.md would pass the check and crash here with EISDIR.
Consider wrapping the read (and optionally the resolveSource check) in a try-catch that maps fs errors to SkillImportError:
Suggestion:
| const src = resolveSource(folderPath.trim()); | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| const src = resolveSource(folderPath.trim()); | |
| let raw: string; | |
| try { | |
| raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| } catch (err: any) { | |
| throw new SkillImportError('NOT_FOUND', `无法读取 ${src.skillMd}:${err?.message ?? err}`); | |
| } |
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | ||
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); |
There was a problem hiding this comment.
Both reconcileSync and reconcileBundledSync are wrapped in a single try/catch. If reconcileSync throws (its orphan-cleanup section — fs.readdirSync / fs.rmSync — is NOT individually try/caught, unlike the per-skill sync loop), the entirely independent bundled sync is silently skipped for this vault, and the generic warning message won't reveal which half failed.
Since the two operations target different directories (molio--* single-file dirs vs. plain-name multi-file dirs), they are fully independent. Consider giving each its own try/catch so a failure in one doesn't prevent the other from running.
Suggestion:
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | |
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); | |
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | |
| try { | |
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); | |
| } catch (err) { | |
| console.warn(`[skills] Failed to sync library/core skills into vault "${vault.name}":`, err instanceof Error ? err.message : err); | |
| } |
| const openDuplicate = useCallback(async (skill: SkillManifestEntry) => { | ||
| setFormError(null); | ||
| setModal({ mode: 'create', skill: null }); | ||
| try { | ||
| const { instructions } = await api.getSkill(skill.id); | ||
| setModal({ | ||
| mode: 'create', | ||
| skill: null, | ||
| initialMarkdown: serializeSkillMd(`${skill.name} 副本`, skill.description, instructions), | ||
| }); | ||
| } catch (err) { | ||
| setFormError((err as Error).message); | ||
| } | ||
| }, []); |
There was a problem hiding this comment.
Same two-phase pattern as openEdit, with an additional failure mode: show is derived from modal !== null, so if the user closes the empty 'create' modal before getSkill resolves, this second setModal re-opens the closed modal with the duplicated content. It also clobbers anything the user typed into the empty create editor while the fetch was in flight.
Simplest robust fix: fetch first, then open the modal once with initialMarkdown already populated (and only if the flow wasn't cancelled).
| export interface SkillResponse { | ||
| skill: SkillManifestEntry; | ||
| } |
There was a problem hiding this comment.
SkillResponse does not match the actual GET /api/skills/:id response. The daemon returns { skill, instructions: readInstructions(skill.id) } and the web client types it as { skill: SkillManifestEntry; instructions: string } (this is how the edit form populates the SKILL.md editor). Declaring only { skill } here makes the contract incomplete/misleading for the very endpoint that feeds the edit form. Also note that SkillResponse, SkillListResponse, PrefillResponse, and VaultSkillListResponse are not referenced by either the daemon or the web client (the client builds inline types), so these wrappers risk drifting from reality. Suggest adding instructions (and any other fields the endpoint returns) or aligning/removing the unused wrappers so the contract reflects the real payloads.
Suggestion:
| export interface SkillResponse { | |
| skill: SkillManifestEntry; | |
| } | |
| export interface SkillResponse { | |
| skill: SkillManifestEntry; | |
| /** SKILL.md body for the edit form (returned by GET /api/skills/:id). */ | |
| instructions: string; | |
| } |
| export interface ImportSkillRequest { | ||
| raw?: string; | ||
| folderPath?: string; | ||
| } |
There was a problem hiding this comment.
The comment says exactly one of raw/folderPath must be provided, but the type makes both optional and leaves the constraint to runtime enforcement in the daemon route. Consider encoding the mutual exclusivity at the type level (an XOR/discriminated union) so clients that omit both or pass both fail at compile time instead of only at request time.
Suggestion:
| export interface ImportSkillRequest { | |
| raw?: string; | |
| folderPath?: string; | |
| } | |
| export type ImportSkillRequest = | |
| | { raw: string; folderPath?: never } | |
| | { raw?: never; folderPath: string }; |
| export interface VaultSkillEntry { | ||
| id: string; | ||
| name: string; | ||
| description: string; | ||
| builtIn: boolean; |
There was a problem hiding this comment.
VaultSkillEntry duplicates most metadata fields of SkillManifestEntry (id, name, description, builtIn, kind, createdAt, updatedAt) and only differs in the enablement flags. Extracting a shared base (e.g. SkillBase) and extending it for both would reduce the risk of one representation being updated without the other.
| function frontmatterField(content: string, key: string): string | null { | ||
| const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); | ||
| if (!fmMatch || !fmMatch[1]) return null; | ||
| const re = new RegExp(`^${key}:\\s*(.*)$`, 'm'); |
There was a problem hiding this comment.
The \s* in this regex matches newlines (the m flag only affects ^/$, not \s). When a frontmatter value is empty or whitespace-only, the greedy \s* consumes the trailing line break and (.*) then captures the NEXT line. For a block like name:\ndescription: foo, frontmatterField(content, 'name') returns 'description: foo' instead of ''.
This is reachable on the save path: SkillFormModal.validateMarkdown relies on if (!parsed.name.trim()) to reject an empty name, but the mis-captured non-empty value slips past that guard and a skill gets created/updated with a corrupted name.
Restrict the separator to horizontal whitespace so it can never cross a line boundary. (The daemon mirror in apps/daemon/src/core/skills/skillmd.ts has the same regex and should be fixed consistently.)
Suggestion:
| const re = new RegExp(`^${key}:\\s*(.*)$`, 'm'); | |
| const re = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'); |
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | ||
| return path.join(skillsDir(opts), id); | ||
| } |
There was a problem hiding this comment.
Path traversal risk: id is interpolated directly into filesystem paths without validation. Currently all callers use UUIDs or DB-validated IDs, but this module is the shared path boundary for the skill system and a malicious/corrupted id (e.g. ../../etc) would resolve outside the intended directories. Consider adding a guard here (defense-in-depth) so that any future caller is protected:
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
function assertSafeId(id: string): void {
if (!SAFE_ID.test(id)) throw new Error(`Invalid skill id: ${id}`);
}Call it at the top of skillContentDir and molioSkillDir.
| const skillMd = path.join(input, 'SKILL.md'); | ||
| if (!fs.existsSync(skillMd)) { | ||
| throw new SkillImportError('NOT_FOUND', `该文件夹根目录没有 SKILL.md:${input}`); | ||
| } |
There was a problem hiding this comment.
Bug: existsSync returns true for directories as well as files. If a user's skill folder contains a directory named SKILL.md (or a broken symlink), this check passes, but readFileSync(src.skillMd, 'utf8') in importFromFolder will then throw EISDIR (or ELOOP/EACCES). That raw Node error propagates as an unhelpful HTTP 500 instead of a clear SkillImportError.
Consider validating that SKILL.md is actually a regular file, and wrapping the subsequent readFileSync in a try/catch that converts fs errors into SkillImportError:
Suggestion:
| const skillMd = path.join(input, 'SKILL.md'); | |
| if (!fs.existsSync(skillMd)) { | |
| throw new SkillImportError('NOT_FOUND', `该文件夹根目录没有 SKILL.md:${input}`); | |
| } | |
| const skillMd = path.join(input, 'SKILL.md'); | |
| let skillMdStat: fs.Stats | null = null; | |
| try { skillMdStat = fs.statSync(skillMd); } catch { /* not found */ } | |
| if (!skillMdStat?.isFile()) { | |
| throw new SkillImportError('NOT_FOUND', `该文件夹根目录没有 SKILL.md:${input}`); | |
| } |
| const src = resolveSource(folderPath.trim()); | ||
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
There was a problem hiding this comment.
Missing error handling at fs boundary: readFileSync can throw for reasons resolveSource doesn't guard against — permission errors (EACCES), a TOCTOU race (file removed between statSync and read), encoding errors, or the file being replaced by a directory. These surface as raw Node errors → HTTP 500 rather than a user-actionable SkillImportError. Consider wrapping the read in try/catch and rethrowing as SkillImportError('BAD_REQUEST', ...) so the route returns a meaningful 4xx.
Suggestion:
| const src = resolveSource(folderPath.trim()); | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| const src = resolveSource(folderPath.trim()); | |
| let raw: string; | |
| try { | |
| raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| } catch (err) { | |
| throw new SkillImportError('BAD_REQUEST', `无法读取 SKILL.md:${err instanceof Error ? err.message : err}`); | |
| } |
| const id = entry.name.slice(MOLIO_PREFIX.length); | ||
| if (!enabledSet.has(id)) { | ||
| fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true }); | ||
| } |
There was a problem hiding this comment.
The orphan-cleanup fs.rmSync here is not wrapped in try/catch, unlike the syncSkill loop just above which guards each id individually. A single filesystem error on one orphaned entry (e.g. EACCES on a root-owned mounted docs dir, or EBUSY) will throw out of the loop and abort cleanup of all remaining molio--* orphans in this vault, leaving partial reconciliation state. This also contradicts the module's documented best-effort contract (vault-config.ts: "an EACCES there must degrade to a warning, never abort"). Wrap the removal per-entry so one bad directory doesn't skip the rest, mirroring the error handling used for syncSkill.
Suggestion:
| const id = entry.name.slice(MOLIO_PREFIX.length); | |
| if (!enabledSet.has(id)) { | |
| fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true }); | |
| } | |
| const id = entry.name.slice(MOLIO_PREFIX.length); | |
| if (!enabledSet.has(id)) { | |
| try { | |
| fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true }); | |
| } catch (err) { | |
| console.error(`[skills] Failed to remove orphaned skill dir "${entry.name}":`, err instanceof Error ? err.message : err); | |
| } | |
| } |
| try { | ||
| const effective = getEffectiveSkills(db, vault.id); | ||
|
|
||
| // library + core → molio-- single-file sync (orphan cleanup included). | ||
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | ||
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); | ||
|
|
||
| // bundled → whole-dir sync. Managed = every bundled row the DB knows about | ||
| // (so a toggled-off one gets removed); effective = the subset that's on. | ||
| const allSkills = listSkills(db); | ||
| const managedBundled = new Set(allSkills.filter((s) => s.kind === 'bundled').map((s) => s.id)); | ||
| const effectiveBundled = new Set(effective.filter((s) => s.kind === 'bundled').map((s) => s.id)); | ||
| reconcileBundledSync(effectiveBundled, managedBundled, vault.path); | ||
| } catch (err) { | ||
| console.warn( | ||
| `[skills] Failed to reconcile skills into vault "${vault.name}" (${vault.path}) — ` + | ||
| `likely a write-permission problem on the directory. The vault is still usable. Cause:`, | ||
| err instanceof Error ? err.message : err, | ||
| ); | ||
| } |
There was a problem hiding this comment.
The try/catch wraps both DB reads (getEffectiveSkills → listSkills, getVaultSkillOverrides) and filesystem writes, but the log message always says "likely a write-permission problem on the directory." A genuine DB failure (database locked, corruption, schema mismatch) would be swallowed and misdiagnosed as a permissions issue, potentially leaving all vaults persistently unsynced with no actionable log to guide debugging.
Consider separating the DB-read phase from the FS-write phase so that DB errors propagate (or at least get a distinct, accurate log message), while only FS errors get the best-effort EACCES treatment the doc comment describes.
Suggestion:
| try { | |
| const effective = getEffectiveSkills(db, vault.id); | |
| // library + core → molio-- single-file sync (orphan cleanup included). | |
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | |
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); | |
| // bundled → whole-dir sync. Managed = every bundled row the DB knows about | |
| // (so a toggled-off one gets removed); effective = the subset that's on. | |
| const allSkills = listSkills(db); | |
| const managedBundled = new Set(allSkills.filter((s) => s.kind === 'bundled').map((s) => s.id)); | |
| const effectiveBundled = new Set(effective.filter((s) => s.kind === 'bundled').map((s) => s.id)); | |
| reconcileBundledSync(effectiveBundled, managedBundled, vault.path); | |
| } catch (err) { | |
| console.warn( | |
| `[skills] Failed to reconcile skills into vault "${vault.name}" (${vault.path}) — ` + | |
| `likely a write-permission problem on the directory. The vault is still usable. Cause:`, | |
| err instanceof Error ? err.message : err, | |
| ); | |
| } | |
| let effective: ReturnType<typeof getEffectiveSkills>; | |
| let allSkills: ReturnType<typeof listSkills>; | |
| try { | |
| effective = getEffectiveSkills(db, vault.id); | |
| allSkills = listSkills(db); | |
| } catch (err) { | |
| console.error( | |
| `[skills] DB error while computing effective skills for vault "${vault.name}" (${vault.id}):`, | |
| err instanceof Error ? err.message : err, | |
| ); | |
| return; | |
| } | |
| try { | |
| // library + core → molio-- single-file sync (orphan cleanup included). | |
| const singleFileIds = effective.filter((s) => s.kind !== 'bundled').map((s) => s.id); | |
| reconcileSync(singleFileIds, { ...opts, claudeHome: path.join(vault.path, '.claude') }); | |
| // bundled → whole-dir sync. | |
| const managedBundled = new Set(allSkills.filter((s) => s.kind === 'bundled').map((s) => s.id)); | |
| const effectiveBundled = new Set(effective.filter((s) => s.kind === 'bundled').map((s) => s.id)); | |
| reconcileBundledSync(effectiveBundled, managedBundled, vault.path); | |
| } catch (err) { | |
| console.warn( | |
| `[skills] Failed to reconcile skills into vault "${vault.name}" (${vault.path}) — ` + | |
| `likely a write-permission problem on the directory. The vault is still usable. Cause:`, | |
| err instanceof Error ? err.message : err, | |
| ); | |
| } |
| <VaultSkillRow | ||
| key={skill.id} | ||
| skill={skill} | ||
| onToggle={(enabled) => void toggle(skill.id, enabled)} |
There was a problem hiding this comment.
toggle re-throws on failure (the hook catches the API error, rolls back the optimistic flip, then does throw err). Calling it via void toggle(...) discards the returned promise without attaching a .catch() — void only evaluates the expression, it does not handle rejections. So when a toggle fails (network/server error), this produces an unhandled promise rejection in the console, and the switch silently snaps back with no feedback to the user. Catch the rejection (and ideally surface the error) at the call site.
Suggestion:
| onToggle={(enabled) => void toggle(skill.id, enabled)} | |
| onToggle={(enabled) => { | |
| void toggle(skill.id, enabled).catch((err) => { | |
| // toggle already rolls back the optimistic flip; surface/log the failure | |
| console.error('Failed to toggle vault skill', err); | |
| }); | |
| }} |
| const data = await api.listVaultSkills(vaultId); | ||
| setSkills(data); |
There was a problem hiding this comment.
Race condition: this hook is mounted persistently (VaultSkillsModal is always rendered and calls the hook before if (!show) return null), and vaultId = kb.activeVault?.id changes on every vault switch — triggering refresh in the background even when the modal is closed. Concurrent fetches are not guarded by a request token or AbortController, so if the user switches vault A → B quickly, a slow response for A can resolve after B's and call setSkills with vault A's entries (including A's vaultEnabled values) while the current vault is B. That older request's finally can also set loading=false while B's request is still pending. Capture a monotonically increasing request id (or the current vaultId in a ref) and bail out when the response is stale.
Suggestion:
| const data = await api.listVaultSkills(vaultId); | |
| setSkills(data); | |
| const reqId = ++reqCounter.current; | |
| const data = await api.listVaultSkills(vaultId); | |
| if (reqId !== reqCounter.current) return; // stale response for a previous vault | |
| setSkills(data); |
| const skill = await api.setVaultSkillEnabled(vaultId, skillId, enabled); | ||
| upsert(skill); |
There was a problem hiding this comment.
Stale-response merge: vaultId is captured at call time. If the active vault changes (or a refresh replaces the list) before this PATCH resolves, upsert(skill) still merges the previous vault's entry into the current list. Because skill ids are shared across vaults, this overwrites the current vault's displayed vaultEnabled with the old vault's value (or appends a stale entry if missing), so the UI shows the wrong toggle state and the next toggle is computed from it. Verify the captured vaultId still matches the active vault before upserting (e.g. via a vaultIdRef kept in sync each render), or skip the upsert and rely on the next refresh.
Suggestion:
| const skill = await api.setVaultSkillEnabled(vaultId, skillId, enabled); | |
| upsert(skill); | |
| const skill = await api.setVaultSkillEnabled(vaultId, skillId, enabled); | |
| if (vaultIdRef.current !== vaultId) return; // vault switched mid-flight; drop stale entry | |
| upsert(skill); |
| setInstructions(''); | ||
| setMarkdown(''); | ||
| } | ||
| }, [show, mode, skill, prefillData, initialMarkdown]); |
There was a problem hiding this comment.
skill is listed as a dependency but is never read inside the effect body. Since this effect resets ALL form fields whenever any dependency changes while show is true, an identity change of the unused skill prop would silently discard everything the user has typed. It happens to be dormant today only because SkillsPanel/App.tsx store skill/prefillData in state (stable references) — but the moment a caller derives skill inline (e.g. skill={skills.find(...)} creating a fresh object per render), the form will reset on every parent re-render. Remove skill from the dependency array since it isn't used.
Suggestion:
| }, [show, mode, skill, prefillData, initialMarkdown]); | |
| }, [show, mode, prefillData, initialMarkdown]); |
| role="tab" | ||
| className={`sk-import-switch__btn${source === 'import' ? ' is-active' : ''}`} | ||
| data-testid="skill-source-import" | ||
| onClick={() => setSource('import')} |
There was a problem hiding this comment.
Switching between the paste and import sources only calls setSource(...); it never clears fieldError. So a validation error raised under one source (e.g. "folder required" from the import source) stays visible after switching to the other source, displayed above unrelated form fields. Clear the error when the source changes — apply the same to the paste button's handler.
Suggestion:
| onClick={() => setSource('import')} | |
| onClick={() => { setSource('import'); setFieldError(null); }} |
用户此前须手动把技能拷进工作目录的 .claude/skills/ 才能用,要求理解隐藏目录、 SKILL.md frontmatter、cwd 机制,对写作者/知识工作者门槛过高。本功能提供「技能库 + 开关」抽象:UI 里获取/编辑/开关技能,Molio 负责同步到 Claude Code 能读到的位置, 模型按描述自动调用。 存储 source-of-truth = ~/.molio/skills/<id>/SKILL.md + manifest.json;启用 → 同步到 ~/.claude/skills/molio--<id>/(molio-- 命名空间,reconcile 只删 molio--*,绝不触碰 用户自有技能)。仅 Claude runtime 受益,UI 已标注。 - daemon core/skills/: store(manifest CRUD+原子写) / sync(对账+孤儿清理) / builtin(启动幂等 seed 3 个内置写作技能) / prefill(存为技能一次性 AI 调用+降级) / importer(粘贴/文件夹导入);routes/skills.ts 全套 REST;index.ts 启动 initSkillLibrary() - contracts: skill.ts 共享类型 - web: 设置→新「技能」Tab(SkillsPanel + SkillFormModal);助手消息工具栏「存为技能」 按钮(SaveAsSkillButton + skillPrefillStore + App 全局预填弹窗);useSkills hook - 修复 resolveSkillsSourceDir 回归:新 core/skills/ 模块编译产物 dist/src/core/skills/ 会被误判为打包内置技能目录,加 isBuiltinSkillsDir 标记探测 + 回归测试 - 测试:daemon skills 单测 + routes 集成测试全绿;新增 skills.spec.ts (P1) E2E 4/4; area-map 更新 settings/chat area
manifest.json 退役,改由 SQLite `skills` 表承载元数据与全局开关(按库覆盖仍用 既有 `vault_skills`);因 manifest 从未合入 main,无生产迁移包袱。技能分三类: bundled(docling/wiki-*/remotion/微信,展示+可配,多文件整目录同步到 <vault>/.claude/skills/<slug>/ 明文名)、library(用户自建,单文件 molio--)、 core(写作三件套,隐藏+始终启用+不可配,行为保留)。store 改 DB 化 CRUD, skill-installer 新增 reconcileBundledSync(整目录装/关掉删/不碰未登记同名目录/ 清 deprecated/CLAUDE.md 规则按 effective 收敛),vault-config 组合三通道同步。 UI:core 不渲染、bundled 带徽章且不可编辑。 测试:daemon 890 通过(含新增 bundled-reconcile 6 例),vault-skills E2E 4 例绿。
.kb-modal 限高 80vh + overflow:hidden,但 .kb-modal-body 无 overflow-y, vault 技能列表超长时被静默裁掉且无滚动条。body 改为 flex 滚动容器 (min-height:0 + overflow-y:auto),header/footer 设 flex:none 只滚正文, 惠及所有共用该 chrome 的 KB 弹窗。附回归 E2E:滚动到底后末行须完整 落在弹窗内(旧样式下红)。
- 头部「新建」「导入」合并为单一「新建技能」入口,弹框内切换「新建」 (粘贴 SKILL.md)与「导入文件 / 文件夹」 - 新增 / 编辑 / 复制统一为单一 SKILL.md markdown 编辑器(前端 parse/serialize 镜像 daemon 格式,utils/skillmd.ts) - daemon 支持从本地文件夹 / SKILL.md 路径导入多文件技能:store 以 sourceDir 原样拷贝整棵目录,sync 镜像整个内容目录(含 references/scripts 同级文件) - desktop 新增 showSkillFilePicker(.md 文件选择器),浏览器 / NAS 降级手填路径 - 补充 daemon importer/sync 单测与 skills E2E
augmentPath 在 Windows 上会扫 %LOCALAPPDATA%\Programs\Python 与 %APPDATA%\Python 下全局/--user 安装的 CLI(docling)。原测试只 重定向了 USERPROFILE,没管 LOCALAPPDATA/APPDATA,导致装了 Python 的机器上真实 Scripts 目录泄漏进 PATH,"should not add dirs that do not exist" 断言失败(CI 无 Python 才侥幸绿)。 - beforeEach/afterEach 把 LOCALAPPDATA/APPDATA 一并指向空 tmp 家目录 - 新增 Windows 正向用例:在受控 LOCALAPPDATA 下建 Programs\Python\Python312\Scripts,断言被发现并加入 Path daemon 全量 941 用例 935 通过 0 失败。
- bundled 技能复制兜底:GET /:id 对 bundled 回退读随包 SKILL.md,复制不再得到空技能 - prefill 保存失败不再 unhandled rejection:弹窗停留并展示 externalError - prefill 超时竞态:agent run 晚到时取消孤儿 run - seed 失败时跳过 reconcile fan-out,避免误删各 vault 已同步技能 - library 同步:内容哈希短路(启动不再全量重写)+ tmp/rename 原子替换 - 导入文件夹加限额(1000 文件 / 100MB),防超大目录拖垮 daemon - web 编辑/复制先 fetch 再开弹窗(消除竞态);serializeSkillMd 补 version 行与 daemon 对齐 - 新增 SkillDetailResponse 契约类型;refreshMeta 改条件 UPDATE 避免 updated_at 抖动
- 新增 skills/dirsync.ts:内容哈希比较 + tmp→rename 原子镜像,统一 library 同步与 bundled 安装两条路径;skill-installer 删除私有 version 比较与 copyDirSync(version 只作诊断,同步以哈希为准) - skillmd 生成/解析从 daemon、web 双份镜像收敛到 @molio/contracts 单源 - knowledge.ts 抽 toVaultSkillEntry,消除 GET/PATCH 两处重复构造 - 清理死代码:manifestPath()、countSeededBuiltins()、removeSkillSyncDir、 useSkills.prefill;i18n key claudeOnlyNote → runtimeNote - 合并 test/tools/skill-installer.test.ts 进 test/core/,三份重复的 ALL_BUNDLED/installAll 测试助手收敛到 test/helpers/install-all.ts
冷缓存下 prune 全量扫描(~600 run 目录 ≈4s) + 技能逐库 fan-out(≈1.2s/库) 在端口绑定前同步执行,把 "listening on" 拖过桌面壳的启动超时。改为先绑定 端口、监听回调后再追赶: - index.ts:prune / fan-out / legacy 清理 / preload 探测移入 runDeferredStartupChores,监听回调打印后经 setImmediate 启动 - runs-log-prune:新增 pruneRunLogsAsync(64 条一块让出事件循环) - vault-config:新增 reconcileAllVaultsAsync(库间让出); afterGlobalSkillMutation 改异步,skills 路由 await - desktop main.js:启动超时 10s→30s,启动成功即清定时器 - 附带修复:deleteVault 显式删 vault_skills——旧库表无 FK, CREATE TABLE IF NOT EXISTS 不会回填 CASCADE,会留孤儿行 - 测试:prune 异步等价 + 让出事件循环、fan-out 异步、删库清 override (含无 FK 旧库)、启动顺序、桌面壳 30s 超时
用户报粘贴「name: khazix-writer description: | <描述>」时被平台复制压成 一行的 frontmatter:name 吞掉 description 全部内容、description 提取为空。 - skillmd.ts:按已知字段边界(name/description/version 前瞻)把折叠行重切 回一行一字段,fenced / unfenced 两路都生效;块标量缩进正文不受影响 (提到 name: 的标量内容不是字段);折叠行尾裸 | 指示符照常吞缩进体 - 同时收录本轮容忍解析:BOM/前导空白、无 --- 围栏的字段块、YAML 块标量 (| 多行 / 单行折叠 / > 折叠)、deriveSkillName 名称兜底链 - SkillFormModal:移除点遮罩关闭——表单里的粘贴/手填内容经不起误点, 仅取消/× 可关;保存失败错误内联显示(externalError) - 测试:skillmd 新增含用户原文逐字复现在内的回归用例、importer 无围栏/ 标题兜底用例、E2E 折叠粘贴提取 + 遮罩角点点击不关
createRun 后固定 sleep 200ms 在慢机器上早于假 agent 出字 (cmd shim→node 启动本机实测 ~290ms),cancelAll 杀进程时 缓冲为空,flush 正确地产出 0 条消息→断言失败。 改为等首个 text_delta(先查 getBufferedEvents 兜底 + 10s 超时), 3/3 稳定通过。
参考 Claude Cowork / ChatGPT 的技能配置形态:整屏接管的专属编辑面, 名称/描述并排为紧凑元数据行,SKILL.md 正文编辑器(等宽字体)占满剩余 高度;动作(取消/保存)上移到顶栏,新增 Ctrl/Cmd+S 保存。两个入口 (设置页技能 tab、聊天页「存为技能」)共用同一组件。状态逻辑、三字段 必填校验、SKILL.md 粘贴提取、误触不关闭语义均保持原样。E2E 同 commit 同步。
c1b2e0d to
4516e9f
Compare
|
/open-code-review |
There was a problem hiding this comment.
🔍 OpenCodeReview found 70 issue(s) in this PR.
- ✅ 69 posted as inline comment(s)
- 📝 1 posted as summary
📄 packages/contracts/src/skillmd.ts
frontmatterField builds its matcher with new RegExp(^${key}\s*:\s*(.*)$) without escaping the key. Current callers only pass the compile-time constants 'name'/'description', so there is no issue today, but a future caller passing any user-influenced input would inject regex syntax and break/misparse fields. Escape the key, e.g. key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), to make the function safe by construction.
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | ||
| return path.join(skillsDir(opts), id); | ||
| } |
There was a problem hiding this comment.
The id parameter is interpolated into a filesystem path via path.join() with no validation or normalization. path.join() resolves .. segments and keeps / separators, so an id like ../../foo or a/b would escape ~/.molio/skills/the molio-- namespace or create unintended nested dirs — violating the isolation this module's docstring promises. Today all callers happen to pass DB-sourced ids (randomUUID or hardcoded slugs), so this isn't reachable through the routes, but this helper is the single security boundary for the skill library, and one future caller passing user input (route param, SKILL.md frontmatter, imported filename) would silently turn into arbitrary file read/write/delete. Recommend validating the id is a single safe path segment inside these helpers (e.g. reject empty, ./.., /, \, and path.basename mismatches) so the invariant is enforced at the boundary regardless of caller.
Suggestion:
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | |
| return path.join(skillsDir(opts), id); | |
| } | |
| function assertSafeSkillId(id: string): void { | |
| if (!id || id.length > 128 || id !== path.basename(id) || id === '.' || id === '..') { | |
| throw new Error(`Invalid skill id: ${id}`); | |
| } | |
| } | |
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | |
| assertSafeSkillId(id); | |
| return path.join(skillsDir(opts), id); | |
| } |
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | ||
| if (result.removed > 0) { |
There was a problem hiding this comment.
The summary is suppressed unless something was actually removed. If the sweep fails on every expired directory (e.g., EACCES, or a persistent lock on Windows), failed grows while removed stays 0 and no diagnostic is emitted at all — the disk fills up silently. This helper is now shared by the production startup path (pruneRunLogsAsync), whose result is discarded by the caller in index.ts, so per-entry failures are completely invisible. Log the summary whenever failed > 0 as well.
Suggestion:
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | |
| if (result.removed > 0) { | |
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | |
| if (result.removed > 0 || result.failed > 0) { |
| let entries: string[]; | ||
| try { | ||
| entries = fs.readdirSync(dir); | ||
| } catch { | ||
| // Directory doesn't exist yet (fresh install) — nothing to do. | ||
| return result; | ||
| } |
There was a problem hiding this comment.
This catch swallows every readdirSync failure, not just ENOENT. A permission error (EACCES) on ~/.molio/runs is silently treated as "nothing to do" and, since the caller discards the result, the failure is completely invisible. Distinguish ENOENT from other errors and log the latter so an unwritable/pruned-by-other-process directory is diagnosable.
Suggestion:
| let entries: string[]; | |
| try { | |
| entries = fs.readdirSync(dir); | |
| } catch { | |
| // Directory doesn't exist yet (fresh install) — nothing to do. | |
| return result; | |
| } | |
| let entries: string[]; | |
| try { | |
| entries = fs.readdirSync(dir); | |
| } catch (err) { | |
| if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { | |
| console.warn(`[runs-log-prune] cannot read ${dir}:`, err); | |
| } | |
| return result; | |
| } |
| if (src.type === 'dir') assertFolderWithinLimits(src.dir); | ||
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
There was a problem hiding this comment.
The lone-file import path skips the size guard entirely: assertFolderWithinLimits is only invoked for dir sources, and resolveSource's file branch accepts ANY file (no .md extension check). Pointing the import at a multi-GB arbitrary file makes fs.readFileSync buffer the whole file into memory, risking daemon OOM. MAX_IMPORT_BYTES is defined but never enforced on this path. Apply the size limit before reading (and ideally restrict the file branch to .md files, matching the doc comment).
Suggestion:
| if (src.type === 'dir') assertFolderWithinLimits(src.dir); | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| if (src.type === 'dir') { | |
| assertFolderWithinLimits(src.dir); | |
| } else { | |
| const size = fs.statSync(src.skillMd).size; | |
| if (size > MAX_IMPORT_BYTES) { | |
| throw new SkillImportError( | |
| 'BAD_REQUEST', | |
| `导入文件大小超过上限(最大 ${Math.round(MAX_IMPORT_BYTES / 1024 / 1024)} MB)`, | |
| ); | |
| } | |
| } | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
| let size = 0; | ||
| try { | ||
| size = fs.statSync(p).size; // follows symlinks, like copyDirSync does | ||
| } catch { | ||
| continue; // broken symlink etc. — copyDirSync will skip/fail on it later | ||
| } |
There was a problem hiding this comment.
The limit walk and the actual copy disagree on symlinks: statSync here follows symlinks, but copyDirSync treats symlinks as regular entries (Dirent.isDirectory() is false for a symlink) and copies them with fs.copyFileSync. A symlink pointing at a directory passes the walk (counted as a single small 'file') but makes copyFileSync throw EISDIR mid-copy — the import dies with an unhandled fs error after partially copying files into ~/.molio/skills/<id>/, orphaning that content dir. Broken symlinks are likewise skipped by this walk (continue) yet throw ENOENT in the copy. Handle symlinks explicitly (lstat + skip/resolve) and clean up the partial destination on copy failure so imports fail cleanly instead of leaking partial content.
| if (/^\s*(```|~~~)/.test(line)) { | ||
| inFence = !inFence; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
firstHeadingText toggles inFence on any line starting with or ~~~ without tracking fence character or length. With a mismatched pair (e.g. opened with but closed with ~~~~, or a ~~~ strikethrough line mis-detected as a fence opener), inFence inverts and all subsequent real headings are skipped during name fallback, silently worsening the derived name. Track the fence marker (e.g. store the opening string and only close on a matching/equally-long delimiter) instead of a bare boolean toggle.
| const parts = line.split(COLLAPSED_FIELD); | ||
| fieldLines.push(...parts); |
There was a problem hiding this comment.
The collapsed-field/block-scalar splitting logic is duplicated between expandCollapsedFields (fenced path) and the inline loop in splitFrontmatter (unfenced path). Both must stay in sync; a future fix to one path (e.g. constraining COLLAPSED_FIELD to avoid value corruption) could leave the other inconsistent, causing divergent parse results between fenced and unfenced pastes. Consider extracting a single shared line-splitting helper used by both paths.
| while (parts.length && parts[parts.length - 1] === '') parts.pop(); | ||
| const joined = parts.join('\n'); | ||
| if (!folded) return joined.trim(); | ||
| return joined.replace(/([^\n])\n(?!\n)/g, '$1 ').replace(/\n{2,}/g, '\n').trim(); |
There was a problem hiding this comment.
readBlockScalar ignores YAML chomping indicators (|+ should keep trailing newlines, >- should strip them) and collapses multiple blank lines to a single \n via \n{2,} → \n. For folded > scalars this also loses the distinction between a single newline (→ space) and a paragraph break. Round-tripped multi-paragraph descriptions are therefore not byte-identical to the original — minor for a tolerant parser, but worth noting if the importer/editor ever relies on preserving description formatting. If exact fidelity isn't required, at least document the lossy behavior.
| if (/^\s*(```|~~~)/.test(line)) { | ||
| inFence = !inFence; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
firstHeadingText toggles inFence on any line starting with ``` or ~~~ without tracking fence character or length. With a mismatched pair (e.g. opened with ``` but closed with ~~~~, or a ~~~ strikethrough line mis-detected as a fence opener), inFence inverts and all subsequent real headings are skipped during name fallback, silently worsening the derived name. Track the fence marker (e.g. store the opening string and only close on a matching/equally-long delimiter) instead of a bare boolean toggle.
| const parts = line.split(COLLAPSED_FIELD); | ||
| fieldLines.push(...parts); |
There was a problem hiding this comment.
The collapsed-field/block-scalar splitting logic is duplicated between expandCollapsedFields (fenced path) and the inline loop in splitFrontmatter (unfenced path). Both must stay in sync; a future fix to one path (e.g. constraining COLLAPSED_FIELD to avoid value corruption) could leave the other inconsistent, causing divergent parse results between fenced and unfenced pastes. Consider extracting a single shared line-splitting helper used by both paths.
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | ||
| return path.join(skillsDir(opts), id); | ||
| } |
There was a problem hiding this comment.
The id parameter is interpolated into a filesystem path via path.join() with no validation or normalization. path.join() resolves .. segments and keeps / separators, so an id like ../../foo or a/b would escape ~/.molio/skills/the molio-- namespace or create unintended nested dirs — violating the isolation this module's docstring promises. Today all callers happen to pass DB-sourced ids (randomUUID or hardcoded slugs), so this isn't reachable through the routes, but this helper is the single security boundary for the skill library, and one future caller passing user input (route param, SKILL.md frontmatter, imported filename) would silently turn into arbitrary file read/write/delete. Recommend validating the id is a single safe path segment inside these helpers (e.g. reject empty, ./.., /, \, and path.basename mismatches) so the invariant is enforced at the boundary regardless of caller.
Suggestion:
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | |
| return path.join(skillsDir(opts), id); | |
| } | |
| function assertSafeSkillId(id: string): void { | |
| if (!id || id.length > 128 || id !== path.basename(id) || id === '.' || id === '..') { | |
| throw new Error(`Invalid skill id: ${id}`); | |
| } | |
| } | |
| export function skillContentDir(id: string, opts?: SkillPathsOpts): string { | |
| assertSafeSkillId(id); | |
| return path.join(skillsDir(opts), id); | |
| } |
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | ||
| if (result.removed > 0) { |
There was a problem hiding this comment.
The summary is suppressed unless something was actually removed. If the sweep fails on every expired directory (e.g., EACCES, or a persistent lock on Windows), failed grows while removed stays 0 and no diagnostic is emitted at all — the disk fills up silently. This helper is now shared by the production startup path (pruneRunLogsAsync), whose result is discarded by the caller in index.ts, so per-entry failures are completely invisible. Log the summary whenever failed > 0 as well.
Suggestion:
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | |
| if (result.removed > 0) { | |
| function logPruneSummary(result: PruneRunLogsResult, dir: string): void { | |
| if (result.removed > 0 || result.failed > 0) { |
| let entries: string[]; | ||
| try { | ||
| entries = fs.readdirSync(dir); | ||
| } catch { | ||
| // Directory doesn't exist yet (fresh install) — nothing to do. | ||
| return result; | ||
| } |
There was a problem hiding this comment.
This catch swallows every readdirSync failure, not just ENOENT. A permission error (EACCES) on ~/.molio/runs is silently treated as "nothing to do" and, since the caller discards the result, the failure is completely invisible. Distinguish ENOENT from other errors and log the latter so an unwritable/pruned-by-other-process directory is diagnosable.
Suggestion:
| let entries: string[]; | |
| try { | |
| entries = fs.readdirSync(dir); | |
| } catch { | |
| // Directory doesn't exist yet (fresh install) — nothing to do. | |
| return result; | |
| } | |
| let entries: string[]; | |
| try { | |
| entries = fs.readdirSync(dir); | |
| } catch (err) { | |
| if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { | |
| console.warn(`[runs-log-prune] cannot read ${dir}:`, err); | |
| } | |
| return result; | |
| } |
| if (src.type === 'dir') assertFolderWithinLimits(src.dir); | ||
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
There was a problem hiding this comment.
The lone-file import path skips the size guard entirely: assertFolderWithinLimits is only invoked for dir sources, and resolveSource's file branch accepts ANY file (no .md extension check). Pointing the import at a multi-GB arbitrary file makes fs.readFileSync buffer the whole file into memory, risking daemon OOM. MAX_IMPORT_BYTES is defined but never enforced on this path. Apply the size limit before reading (and ideally restrict the file branch to .md files, matching the doc comment).
Suggestion:
| if (src.type === 'dir') assertFolderWithinLimits(src.dir); | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| if (src.type === 'dir') { | |
| assertFolderWithinLimits(src.dir); | |
| } else { | |
| const size = fs.statSync(src.skillMd).size; | |
| if (size > MAX_IMPORT_BYTES) { | |
| throw new SkillImportError( | |
| 'BAD_REQUEST', | |
| `导入文件大小超过上限(最大 ${Math.round(MAX_IMPORT_BYTES / 1024 / 1024)} MB)`, | |
| ); | |
| } | |
| } | |
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
| let size = 0; | ||
| try { | ||
| size = fs.statSync(p).size; // follows symlinks, like copyDirSync does | ||
| } catch { | ||
| continue; // broken symlink etc. — copyDirSync will skip/fail on it later | ||
| } |
There was a problem hiding this comment.
The limit walk and the actual copy disagree on symlinks: statSync here follows symlinks, but copyDirSync treats symlinks as regular entries (Dirent.isDirectory() is false for a symlink) and copies them with fs.copyFileSync. A symlink pointing at a directory passes the walk (counted as a single small 'file') but makes copyFileSync throw EISDIR mid-copy — the import dies with an unhandled fs error after partially copying files into ~/.molio/skills/<id>/, orphaning that content dir. Broken symlinks are likewise skipped by this walk (continue) yet throw ENOENT in the copy. Handle symlinks explicitly (lstat + skip/resolve) and clean up the partial destination on copy failure so imports fail cleanly instead of leaking partial content.
| copyDirSync(srcDir, tmp); | ||
| fs.rmSync(destDir, { recursive: true, force: true }); | ||
| fs.renameSync(tmp, destDir); | ||
| return true; |
There was a problem hiding this comment.
The "atomic swap" claim only holds for the rename step, not the whole sequence. fs.rmSync(destDir) followed by fs.renameSync(tmp, destDir) leaves a window where concurrent readers (agent CLI scanning .claude/skills/) see ENOENT for the dest dir. Worse: if renameSync fails (EPERM on an ownership-changed NAS mount, AV lock on Windows), destDir has already been deleted and the catch only cleans up tmp — the skill dir is silently lost for that vault until the next reconcile. This contradicts the best-effort/EACCES-degrades-to-warning policy in vault-config.ts. Use a two-phase swap with a backup so a failed rename restores the previous copy instead of deleting it.
Suggestion:
| copyDirSync(srcDir, tmp); | |
| fs.rmSync(destDir, { recursive: true, force: true }); | |
| fs.renameSync(tmp, destDir); | |
| return true; | |
| copyDirSync(srcDir, tmp); | |
| const backup = `${destDir}.bak-${Date.now()}-${Math.random().toString(36).slice(2)}`; | |
| if (fs.existsSync(destDir)) fs.renameSync(destDir, backup); | |
| try { | |
| fs.renameSync(tmp, destDir); | |
| } catch (err) { | |
| if (fs.existsSync(backup)) fs.renameSync(backup, destDir); // restore previous copy | |
| throw err; | |
| } | |
| fs.rmSync(backup, { recursive: true, force: true }); | |
| return true; |
| } else { | ||
| fs.copyFileSync(srcPath, destPath); | ||
| } |
There was a problem hiding this comment.
hashDir skips symlinks (neither isDirectory() nor isFile()), but copyDirSync copies them as regular files via copyFileSync, which follows the link. Consequences: (1) any source tree containing a symlink never hash-matches the copied dest, so that skill is rebuilt on every daemon start — defeating the short-circuit this module exists for, on exactly the NAS path it targets; (2) a symlink pointing at a directory makes copyFileSync throw EISDIR, crashing the whole mirror (the "never a wrong result" comment is not true for this case); (3) a symlink pointing outside the source copies external file content into the vault. Make symlink handling consistent in both functions (skip in both, or hash and copy them uniformly).
Suggestion:
| } else { | |
| fs.copyFileSync(srcPath, destPath); | |
| } | |
| if (entry.isDirectory()) { | |
| copyDirSync(srcPath, destPath); | |
| } else if (entry.isSymbolicLink()) { | |
| // Keep consistent with hashDir (which ignores symlinks): skip rather than | |
| // follow, so the dest hash can match and copyFileSync can't crash on a | |
| // link-to-directory or leak external file content into the vault. | |
| } else { | |
| fs.copyFileSync(srcPath, destPath); | |
| } |
| walk(p, r); | ||
| } else if (entry.isFile()) { | ||
| hash.update(`f:${r}\n`); | ||
| hash.update(fs.readFileSync(p)); |
There was a problem hiding this comment.
hashDir reads each file fully into memory via fs.readFileSync with no per-file size guard. Although the importer caps a folder at 100MB/1000 files, a single file can still be ~100MB, and this hash runs per vault on every daemon-start fan-out (and again on the copy pass). Peak transient memory is ~2× the largest file per sync. Stream the content through the hash in fixed-size chunks so memory stays bounded regardless of file size.
Suggestion:
| hash.update(fs.readFileSync(p)); | |
| hash.update(`f:${r}\n`); | |
| const fd = fs.openSync(p, 'r'); | |
| try { | |
| const buf = Buffer.alloc(64 * 1024); | |
| let n: number; | |
| while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) { | |
| hash.update(buf.subarray(0, n)); | |
| } | |
| } finally { | |
| fs.closeSync(fd); | |
| } | |
| hash.update('\n'); |
| export function mirrorDirIfChanged(srcDir: string, destDir: string): boolean { | ||
| if (isAlreadySynced(srcDir, destDir)) return false; | ||
|
|
||
| const tmp = `${destDir}.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
There was a problem hiding this comment.
The tmp dir is created as a sibling of destDir inside the same .claude/skills/ directory the runtime CLIs scan for SKILL.md. During a rebuild, a concurrent CLI scan can see <slug>.tmp-* — and since SKILL.md is copied early, may load a half-copied skill, which is exactly what the atomic-swap comment claims to prevent. Also, if the process is killed mid-copy (SIGKILL/power loss), the orphaned .tmp-* dir persists; for plain-name bundled skills it is never swept by the molio-- orphan cleanup and keeps being picked up by later scans. Consider staging tmp dirs outside the scanned directory and sweeping stale .tmp-*/.bak-* siblings at startup.
| export function mirrorDirIfChanged(srcDir: string, destDir: string): boolean { | ||
| if (isAlreadySynced(srcDir, destDir)) return false; |
There was a problem hiding this comment.
When a rebuild is needed, the source tree is read twice: fully by hashDir (inside isAlreadySynced) and again by copyDirSync. On the every-vault-every-start fan-out this doubles source I/O, which is still costly on NAS-mounted vaults. Consider computing the hash while copying in a single pass, or cheaply short-circuiting on size/mtime mismatch before hashing.
| if (!vaultId) { | ||
| setSkills([]); | ||
| return; | ||
| } |
There was a problem hiding this comment.
When vaultId becomes null while a refresh is in flight, loading gets stuck at true forever: the null branch only clears skills and returns, while the in-flight refresh's finally is skipped because vaultIdRef.current !== vaultId. Since the modal stays mounted with a null vaultId (per the hook's own contract), the UI renders an indefinite spinner after the user deselects a vault during a load. Reset loading/error in the null branch too:
Suggestion:
| if (!vaultId) { | |
| setSkills([]); | |
| return; | |
| } | |
| if (!vaultId) { | |
| setSkills([]); | |
| setLoading(false); | |
| setError(null); | |
| return; | |
| } |
| const handleDeleteConfirm = useCallback(async (id: string) => { | ||
| setConfirmDeleteId(null); | ||
| try { | ||
| await deleteSkill(id); | ||
| } catch (err) { | ||
| setFormError((err as Error).message); | ||
| } | ||
| }, [deleteSkill]); |
There was a problem hiding this comment.
Delete does not participate in the openSeqRef sequence guard. The delete button is not disabled while fetchingId === skill.id, so a user can click Edit/Duplicate on a skill, then confirm Delete while api.getSkill is still in flight. When the fetch resolves, the seq check passes (no newer open happened) and the editor opens for a skill that has just been deleted — saving then fails with a 404 (updateSkill on a removed id), or the modal shows content for a skill that no longer exists. Invalidate any in-flight open-fetch when a delete is confirmed: openSeqRef.current += 1; setFetchingId(null); inside handleDeleteConfirm before the API call.
Suggestion:
| const handleDeleteConfirm = useCallback(async (id: string) => { | |
| setConfirmDeleteId(null); | |
| try { | |
| await deleteSkill(id); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [deleteSkill]); | |
| const handleDeleteConfirm = useCallback(async (id: string) => { | |
| setConfirmDeleteId(null); | |
| openSeqRef.current += 1; // invalidate any in-flight edit/duplicate fetch | |
| setFetchingId(null); | |
| try { | |
| await deleteSkill(id); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [deleteSkill]); |
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | ||
| try { | ||
| await toggleSkill(id, enabled); | ||
| } catch (err) { | ||
| setFormError((err as Error).message); | ||
| } | ||
| }, [toggleSkill]); |
There was a problem hiding this comment.
formError is only set on failure in handleToggle/handleDeleteConfirm and never cleared on success. After a failed toggle/delete, the error banner stays visible indefinitely — even after a subsequent action succeeds — because nothing resets formError (only openEdit/openDuplicate/openNew/handleSave/handleImport reset it). Clear formError at the start of these handlers so a stale error doesn't linger.
Suggestion:
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | |
| try { | |
| await toggleSkill(id, enabled); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [toggleSkill]); | |
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | |
| setFormError(null); | |
| try { | |
| await toggleSkill(id, enabled); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [toggleSkill]); |
| try { | ||
| const { instructions } = await api.getSkill(skill.id); | ||
| if (openSeqRef.current !== seq) return; // superseded by a newer open |
There was a problem hiding this comment.
openEdit/openDuplicate await api.getSkill with no abort/unmount guard. SkillsPanel is conditionally mounted ({activeTab === 'skills' && <SkillsPanel />}), so switching tabs while the fetch is pending leaves the resolved promise calling setModal/setFormError/setFetchingId on an unmounted component. React 18 silently drops these, but this is a stale async completion that can trigger act() warnings in tests and would misbehave under other React versions. Invalidate the sequence on unmount (e.g. useEffect(() => () => { openSeqRef.current += 1; }, [])).
Suggestion:
| try { | |
| const { instructions } = await api.getSkill(skill.id); | |
| if (openSeqRef.current !== seq) return; // superseded by a newer open | |
| useEffect(() => () => { openSeqRef.current += 1; }, []); | |
| const openEdit = useCallback(async (skill: SkillManifestEntry) => { | |
| const seq = ++openSeqRef.current; | |
| setFormError(null); | |
| setFetchingId(skill.id); | |
| try { | |
| const { instructions } = await api.getSkill(skill.id); | |
| if (openSeqRef.current !== seq) return; // superseded by a newer open |
| const handleDeleteConfirm = useCallback(async (id: string) => { | ||
| setConfirmDeleteId(null); | ||
| try { | ||
| await deleteSkill(id); | ||
| } catch (err) { | ||
| setFormError((err as Error).message); | ||
| } | ||
| }, [deleteSkill]); |
There was a problem hiding this comment.
Delete does not participate in the openSeqRef sequence guard. The delete button is not disabled while fetchingId === skill.id, so a user can click Edit/Duplicate on a skill, then confirm Delete while api.getSkill is still in flight. When the fetch resolves, the seq check passes (no newer open happened) and the editor opens for a skill that has just been deleted — saving then fails with a 404 (updateSkill on a removed id), or the modal shows content for a skill that no longer exists. Invalidate any in-flight open-fetch when a delete is confirmed: openSeqRef.current += 1; setFetchingId(null); inside handleDeleteConfirm before the API call.
Suggestion:
| const handleDeleteConfirm = useCallback(async (id: string) => { | |
| setConfirmDeleteId(null); | |
| try { | |
| await deleteSkill(id); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [deleteSkill]); | |
| const handleDeleteConfirm = useCallback(async (id: string) => { | |
| setConfirmDeleteId(null); | |
| openSeqRef.current += 1; // invalidate any in-flight edit/duplicate fetch | |
| setFetchingId(null); | |
| try { | |
| await deleteSkill(id); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [deleteSkill]); |
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | ||
| try { | ||
| await toggleSkill(id, enabled); | ||
| } catch (err) { | ||
| setFormError((err as Error).message); | ||
| } | ||
| }, [toggleSkill]); |
There was a problem hiding this comment.
formError is only set on failure in handleToggle/handleDeleteConfirm and never cleared on success. After a failed toggle/delete, the error banner stays visible indefinitely — even after a subsequent action succeeds — because nothing resets formError (only openEdit/openDuplicate/openNew/handleSave/handleImport reset it). Clear formError at the start of these handlers so a stale error doesn't linger.
Suggestion:
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | |
| try { | |
| await toggleSkill(id, enabled); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [toggleSkill]); | |
| const handleToggle = useCallback(async (id: string, enabled: boolean) => { | |
| setFormError(null); | |
| try { | |
| await toggleSkill(id, enabled); | |
| } catch (err) { | |
| setFormError((err as Error).message); | |
| } | |
| }, [toggleSkill]); |
| try { | ||
| const { instructions } = await api.getSkill(skill.id); | ||
| if (openSeqRef.current !== seq) return; // superseded by a newer open |
There was a problem hiding this comment.
openEdit/openDuplicate await api.getSkill with no abort/unmount guard. SkillsPanel is conditionally mounted ({activeTab === 'skills' && <SkillsPanel />}), so switching tabs while the fetch is pending leaves the resolved promise calling setModal/setFormError/setFetchingId on an unmounted component. React 18 silently drops these, but this is a stale async completion that can trigger act() warnings in tests and would misbehave under other React versions. Invalidate the sequence on unmount (e.g. useEffect(() => () => { openSeqRef.current += 1; }, [])).
Suggestion:
| try { | |
| const { instructions } = await api.getSkill(skill.id); | |
| if (openSeqRef.current !== seq) return; // superseded by a newer open | |
| useEffect(() => () => { openSeqRef.current += 1; }, []); | |
| const openEdit = useCallback(async (skill: SkillManifestEntry) => { | |
| const seq = ++openSeqRef.current; | |
| setFormError(null); | |
| setFetchingId(skill.id); | |
| try { | |
| const { instructions } = await api.getSkill(skill.id); | |
| if (openSeqRef.current !== seq) return; // superseded by a newer open |
| /** 'bundled' (multi-file, shipped) or 'library' (single-file, user-managed). Defaults to 'library'. */ | ||
| kind?: SkillKind; |
There was a problem hiding this comment.
The doc comment above states kind "Defaults to 'library'", but the field is declared optional (kind?). In practice the daemon always populates it — store.ts rowToEntry always sets kind explicitly and the DB schema defaults it to 'library' (NOT NULL DEFAULT 'library') — and the web UI (SkillsPanel/VaultSkillsModal) treats any non-'bundled' value as library. Making the field required (kind: SkillKind) would align the type with both the documented default and the actual wire behavior, and let consumers rely on it without undefined checks.
| export interface VaultSkillEntry { | ||
| id: string; | ||
| name: string; | ||
| description: string; | ||
| builtIn: boolean; |
There was a problem hiding this comment.
VaultSkillEntry duplicates id/name/description/builtIn/kind/createdAt/updatedAt from SkillManifestEntry (the daemon's toVaultSkillEntry in routes/knowledge.ts mirrors them field-by-field). Since this contracts file is the single source of truth for the API surface, consider extracting a shared metadata base type (e.g. interface SkillMetadata { id; name; description; builtIn; kind?; createdAt; updatedAt }) that both SkillManifestEntry and VaultSkillEntry extend. That way future metadata changes (e.g. adding author/version) don't need to be mirrored across two interfaces and the inline comments can't drift out of sync.
| while (i < lines.length) { | ||
| const line = lines[i] ?? ''; | ||
| if (!FIELD_LINE.test(line)) break; // a blank line or body text ends the unfenced block | ||
| i += 1; |
There was a problem hiding this comment.
The unfenced path stops consuming field lines at the first blank line (if (!FIELD_LINE.test(line)) break). Real pastes often have blank lines between YAML keys (name: X\n\ndescription: Y), so the description/version get dropped from the frontmatter and the remainder becomes body text — inconsistent with the fenced path, which has no such limitation. Consider allowing a single blank line within the field run (skipping it in the loop) so the unfenced fallback matches the fenced behavior for the same content.
| `---\n` + | ||
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + | ||
| `version: 1.0.0\n` + |
There was a problem hiding this comment.
generateSkillMd hardcodes version: 1.0.0, and parseSkillMd/ParsedSkillMd neither read nor preserve a version field. For the round-trip path (import → edit → save), the original version is silently dropped and reset to 1.0.0 on every generate. If version fidelity matters for the skill format, add version to ParsedSkillMd and re-emit the parsed value (defaulting to 1.0.0); otherwise the hardcoded constant is fine but worth an explicit note in the docs. At minimum, the inconsistency between parsing version as a known field and never surfacing it should be intentional and documented.
| const lines = frontmatter.split(/\r?\n/); | ||
| for (let idx = 0; idx < lines.length; idx++) { | ||
| const line = lines[idx] ?? ''; | ||
| const m = line.match(new RegExp(`^${key}\\s*:\\s*(.*)$`)); |
There was a problem hiding this comment.
frontmatterField interpolates the key parameter directly into a RegExp constructor (new RegExp(^${key}\s*:\s*(.*)$)) without escaping. All current call sites pass static strings ('name', 'description'), so there is no immediate trigger, but this is a latent regex-injection/disruption hazard if the function is ever reused with dynamic input (e.g. reading a user-specified field name). Escaping the key with key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') would make it safe for any input.
| while (i < lines.length) { | ||
| const line = lines[i] ?? ''; | ||
| if (!FIELD_LINE.test(line)) break; // a blank line or body text ends the unfenced block | ||
| i += 1; |
There was a problem hiding this comment.
The unfenced path stops consuming field lines at the first blank line (if (!FIELD_LINE.test(line)) break). Real pastes often have blank lines between YAML keys (name: X\n\ndescription: Y), so the description/version get dropped from the frontmatter and the remainder becomes body text — inconsistent with the fenced path, which has no such limitation. Consider allowing a single blank line within the field run (skipping it in the loop) so the unfenced fallback matches the fenced behavior for the same content.
| `---\n` + | ||
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + | ||
| `version: 1.0.0\n` + |
There was a problem hiding this comment.
generateSkillMd hardcodes version: 1.0.0, and parseSkillMd/ParsedSkillMd neither read nor preserve a version field. For the round-trip path (import → edit → save), the original version is silently dropped and reset to 1.0.0 on every generate. If version fidelity matters for the skill format, add version to ParsedSkillMd and re-emit the parsed value (defaulting to 1.0.0); otherwise the hardcoded constant is fine but the inconsistency between parsing version as a known field and never surfacing it should be intentional and documented.
| const lines = frontmatter.split(/\r?\n/); | ||
| for (let idx = 0; idx < lines.length; idx++) { | ||
| const line = lines[idx] ?? ''; | ||
| const m = line.match(new RegExp(`^${key}\\s*:\\s*(.*)$`)); |
There was a problem hiding this comment.
frontmatterField interpolates the key parameter directly into a RegExp constructor (new RegExp(^${key}\s*:\s*(.*)$)) without escaping. All current call sites pass static strings ('name', 'description'), so there is no immediate trigger, but this is a latent regex-injection/disruption hazard if the function is ever reused with dynamic input (e.g. reading a user-specified field name). Escaping the key with key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') would make it safe for any input.
|
🔍 OpenCodeReview found 52 issue(s) in this PR.
📊 Posting Statistics:
|
多库技能差异化场景不明确,两个开关的心智模型对非技术用户难解释; PR 未合、无线上数据,此时砍几乎免费。同步层(per-vault fan-out)不动。 - daemon: 删 vault_skills 表、override 增删查 3 函数、GET/PATCH /vaults/:id/skills - vault-config: getEffectiveSkills 退化为 core + 全局启用,保留 vaultId 签名 (将来加回按库过滤只插 filter,不动同步层) - contracts: 删 VaultSkillEntry / VaultSkillListResponse / VaultSkillToggleRequest - web: 删 VaultSkillsModal/useVaultSkills、KB 工具栏技能按钮、i18n 10 条 - 测试: 删 knowledge-vault-skills.test.ts、vault-skills.spec.ts、area-map 引用; vault-config.test.ts 改测全局开关 + core 豁免 验证: typecheck 全绿、daemon 1063 过 0 失败、skills E2E 9/9、 create-vault-form+knowledge 13/13、打包版人工验证通过
| // continue; skills sync on a later start once mount permissions are fixed. | ||
| try { | ||
| installSkills(defaultPath); | ||
| reconcileSkills(db, vault); |
There was a problem hiding this comment.
Initialization-order issue: maybeCreateDefaultVault runs at server.ts module-evaluation time (top-level call), which is BEFORE initSkillLibrary(db) seeds the skills table in index.ts. On a fresh install the table is still empty when reconcileVault runs here, so this creation-time sync is effectively a no-op (empty effective set — reconcileSync([]) + reconcileBundledSync(∅, ∅, …), and ensureMolioRules would even strip gated rules if a CLAUDE.md pre-exists in the mount). The vault's skills only get populated later by reconcileAllVaultsAsync in the post-listen startup chores — and index.ts skips that fan-out entirely when seeding fails (if (skillsSeeded)). The old installBuiltinSkills installed built-ins directly from disk regardless of the DB, so a seeding failure now leaves the freshly-created default vault with NO skills. Consider seeding before default-vault creation (or passing the seeded state into this path) so the vault is reconciled against a populated table.
| id.length > 0 && | ||
| id.length <= 128 && | ||
| id !== '.' && | ||
| id !== '..' && | ||
| !id.includes('/') && | ||
| !id.includes('\\') && | ||
| id === path.basename(id) |
There was a problem hiding this comment.
isValidSkillId is documented as the single choke point guaranteeing that "no caller (DB row, route param, imported filename) can ever escape the skills dirs", but the validation is incomplete for edge-case IDs that would still reach path.join. It does not reject NUL bytes (\0), which make path.join throw ERR_INVALID_ARG_VALUE on every platform (a 500/DoS instead of a clean rejection); it also allows Windows-invalid characters (< > : " | ? *), reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9), and trailing dots/spaces (silently stripped by Windows, causing path mismatches). Additionally, id.length <= 128 counts UTF-16 code units, so a 128-char multi-byte ID (e.g. emoji) plus the molio-- prefix can exceed the ~255-byte filename limit → ENAMETOOLONG. Since the daemon is cross-platform and this module is the stated safety invariant, consider also rejecting \0, forbidden Windows chars/reserved names, and enforcing a byte-length cap via Buffer.byteLength(id).
| export function scratchDir(opts?: SkillPathsOpts): string { | ||
| return path.join(opts?.molioHome ?? defaultMolioHome(), 'scratch'); | ||
| } |
There was a problem hiding this comment.
scratchDir returns a single shared ~/.molio/scratch used as the cwd for every prefill run. The daemon is a plain HTTP server, so concurrent /api/skills/prefill requests (not just the bundled UI, which has its own busy-guard) will run Claude with the same cwd — any file the agent writes there can collide with or be picked up by another run, and there is no cleanup of whatever the agent leaves behind. Consider isolating per run (e.g. scratch/<runId>) and cleaning it up in settle.
| ); | ||
| } | ||
| } | ||
| const raw = fs.readFileSync(src.skillMd, 'utf8'); |
There was a problem hiding this comment.
fs.readFileSync (and the preceding fs.statSync for the single-file branch) sits outside any SkillImportError conversion. If the file is deleted/renamed between resolveSource's stat and this read, or becomes unreadable (EACCES), the raw ENOENT/EPERM propagates to the route's generic catch-all and surfaces as a 500 with a low-level fs message instead of a clean 400/404. Wrap the fs boundary reads in try/catch and convert to SkillImportError.
Suggestion:
| const raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| let raw: string; | |
| try { | |
| raw = fs.readFileSync(src.skillMd, 'utf8'); | |
| } catch { | |
| throw new SkillImportError('NOT_FOUND', `无法读取文件:${src.skillMd}`); | |
| } |
| let files = 0; | ||
| let bytes = 0; | ||
| const walk = (d: string): void => { | ||
| for (const entry of fs.readdirSync(d, { withFileTypes: true })) { |
There was a problem hiding this comment.
The recursive walk calls fs.readdirSync on every subdirectory without a guard. An unreadable subtree (EACCES/EPERM on a protected folder) throws a raw system error that escapes the SkillImportError mapping and yields a 500. Consider skipping unreadable dirs (consistent with copyDirSync's tolerance) or converting to a clean SkillImportError.
Suggestion:
| for (const entry of fs.readdirSync(d, { withFileTypes: true })) { | |
| let entries: fs.Dirent[]; | |
| try { | |
| entries = fs.readdirSync(d, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const entry of entries) { |
| * one line per field. Only the known skill keys count as boundaries — a value | ||
| * that happens to contain some other `word:` (e.g. "see: docs") stays intact. | ||
| */ | ||
| const COLLAPSED_FIELD = /[ \t]+(?=(?:name|description|version)\s*:)/; |
There was a problem hiding this comment.
The COLLAPSED_FIELD lookahead splits on whitespace before ANY occurrence of name:/description:/version:, whether or not that text is a real field declaration. A value such as description: 支持 version: 2 的 API will be split into description: 支持 + version: 2 的 API, truncating the description and injecting a junk field. The existing test only guards the see: (non-known key) case, so values that legitimately mention one of these three key names are silently corrupted after round-tripping through the editor/importer. Consider restricting the split to lines that start with a known skill field and only splitting when the split actually separates well-formed known fields (or quoting/escaping values), so ordinary prose mentioning these keys isn't mangled.
| function splitFrontmatter(content: string): FrontmatterSplit { | ||
| const text = trimLeadingNoise(content); | ||
|
|
||
| const fenced = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/); |
There was a problem hiding this comment.
The fenced-frontmatter regex ^---...\n--- also matches a plain markdown document that happens to start with a --- horizontal rule and contains another --- later. Everything between the two rules is then treated as frontmatter and dropped from the instructions (silent content loss when the result is saved/imported), and any name:/description: text in that region is misread as metadata. Since the module is explicitly tolerant of missing fences, consider only treating the block as frontmatter when it actually contains at least one known field line (name:/description:/version:), otherwise falling through to the plain-body path.
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + |
There was a problem hiding this comment.
name/description are interpolated into the YAML frontmatter without any escaping. User-controlled values containing : (e.g. React: 教程), quotes, or leading #/%/@ produce invalid or ambiguous YAML for strict parsers. The generated SKILL.md is also consumed by runtime CLIs' skill discovery (per the file header), so such values can break external tooling even though this app's own tolerant parser round-trips them. Consider quoting/escaping the values (or validating at input) so the emitted frontmatter stays parseable by external consumers.
| .sk-field__input:focus, | ||
| .sk-field__textarea:focus { | ||
| outline: none; | ||
| border-color: var(--accent); | ||
| } |
There was a problem hiding this comment.
Focus outline removed on form fields: .sk-field__input:focus, .sk-field__textarea:focus { outline: none; } leaves only a border-color change as the focus indicator, which is hard to notice (especially for keyboard/Tab users). This also conflicts with the project's established pattern — the same diff's .sk-switch input:focus-visible and existing .settings-tab-btn:focus-visible / base.css button:focus-visible keep visible outlines. Use :focus-visible with a visible indicator (e.g. outline: 2px solid var(--accent); outline-offset: 2px;) to preserve keyboard accessibility.
Suggestion:
| .sk-field__input:focus, | |
| .sk-field__textarea:focus { | |
| outline: none; | |
| border-color: var(--accent); | |
| } | |
| .sk-field__input:focus-visible, | |
| .sk-field__textarea:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| border-color: var(--accent); | |
| } |
| .sk-field__input:focus, | ||
| .sk-field__textarea:focus { | ||
| outline: none; | ||
| border-color: var(--accent); | ||
| } |
There was a problem hiding this comment.
Focus outline removed on form fields: .sk-field__input:focus, .sk-field__textarea:focus { outline: none; } leaves only a border-color change as the focus indicator, which is hard to notice (especially for keyboard/Tab users). This also conflicts with the project's established pattern — the same diff's .sk-switch input:focus-visible and existing .settings-tab-btn:focus-visible / base.css button:focus-visible keep visible outlines. Use :focus-visible with a visible indicator (e.g. outline: 2px solid var(--accent); outline-offset: 2px;) to preserve keyboard accessibility.
Suggestion:
| .sk-field__input:focus, | |
| .sk-field__textarea:focus { | |
| outline: none; | |
| border-color: var(--accent); | |
| } | |
| .sk-field__input:focus-visible, | |
| .sk-field__textarea:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| border-color: var(--accent); | |
| } |
| } | ||
| let size = 0; | ||
| try { | ||
| size = fs.statSync(p).size; // follows symlinks, like copyDirSync does |
There was a problem hiding this comment.
The limit walk counts symlink targets with fs.statSync (which dereferences links), but the actual copy — copyDirSync in dirsync.ts — SKIPS symlinks (entry.isSymbolicLink() → skip). So the comment "follows symlinks, like copyDirSync does" is inaccurate, and the two rules diverge functionally:
- A skill folder whose reference/script siblings are symlinks (common in git-style repos) passes the limits but imports with those files silently missing — the SKILL.md references break in the library and in every synced vault copy.
- A symlink to a large external file is counted toward MAX_IMPORT_BYTES and can falsely reject an import even though the copy would never include it.
Recommend aligning the walk's rule with the copy (skip links) or explicitly documenting the divergence so the limits and the actual copied content stay consistent.
| let stat: fs.Stats | null = null; | ||
| try { | ||
| stat = fs.statSync(input); | ||
| } catch { | ||
| stat = null; | ||
| } |
There was a problem hiding this comment.
fs errors at the import boundary aren't wrapped into SkillImportError. resolveSource turns EVERY stat failure (including EACCES on a protected path, e.g. macOS TCC-guarded folders) into a "not found" → misleading 404. Meanwhile readdirSync/statSync/readFileSync inside assertFolderWithinLimits/importFromFolder (unreadable subdir, file deleted between the stat and the read) throw raw Node errors that escape the SkillImportError branch in routes/skills.ts and surface as a generic 500 INTERNAL with an OS error message. Map permission/I/O failures to SkillImportError (e.g. a distinct code or BAD_REQUEST) so the API returns a consistent structured 400/404 instead of leaking raw fs exceptions.
| if (now - st.mtimeMs <= maxAgeMs) { | ||
| result.kept++; | ||
| return; | ||
| } | ||
| fs.rmSync(target, { recursive: true, force: true }); |
There was a problem hiding this comment.
Age is determined from the directory's own mtime, which only changes when a direct child is created/deleted — appending to an existing events.jsonl does not touch it. A run directory older than maxAgeDays whose log stream is still being appended (a long-lived multi-turn session, or a child process orphaned across a daemon restart) is classified as expired and rmSync'd mid-write. The old sync sweep ran before the server accepted connections, so it could never hit an active log; pruneRunLogsAsync now yields to the event loop right after listen, so HTTP requests can create/continue runs while the sweep is in progress. Consider judging age from the newest file inside the directory (e.g. events.jsonl's mtime) or skipping directories that belong to currently active runs.
| ): void { | ||
| const target = path.join(dir, name); | ||
| try { | ||
| const st = fs.statSync(target); |
There was a problem hiding this comment.
pruneEntry uses fs.statSync, which follows symlinks. A symlink inside the runs dir that points to a directory is treated as a run-log directory; if the target's mtime is stale, rmSync removes the symlink itself (rmSync does not follow the link) and counts it as 'removed'. The daemon never creates such links, but the sweep already accounts for symlinks in its tests (dangling symlink case), so classifying them as keepable is the safer default — lstatSync would treat any symlink as a non-directory and leave it untouched.
| // continue; skills sync on a later start once mount permissions are fixed. | ||
| try { | ||
| installSkills(defaultPath); | ||
| reconcileSkills(db, vault); |
There was a problem hiding this comment.
On a fresh headless boot, maybeCreateDefaultVault runs during server.ts module evaluation, which is hoisted ahead of index.ts's initSkillLibrary(db) seed (the startup-order test only pins seeding before listen). At provisioning time the skills table is therefore empty: reconcileVault sees no effective/managed skills, so reconcileBundledSync(∅, ∅, …) installs nothing and the gated CLAUDE.md rules (docling/remotion/wiki-query) are not added. The provisioning-time sync becomes a no-op on first boot — unlike the old installBuiltinSkills, which had no DB dependency and installed reliably. Skills only appear after the post-listen reconcileAllVaultsAsync fan-out; if the process is killed before that completes, the new vault stays skill-less until the next restart. Consider seeding the library before maybeCreateDefaultVault runs (e.g. move initSkillLibrary into server.ts before provisioning), or drop the provisioning-time reconcile and rely solely on the deferred fan-out.
| export interface UpdateSkillRequest { | ||
| name?: string; | ||
| description?: string; | ||
| instructions?: string; | ||
| } |
There was a problem hiding this comment.
All fields are optional, so an empty object {} is a valid UpdateSkillRequest. The daemon PATCH handler only checks for a non-null body and forwards it to updateSkill, which then performs a no-op write: it bumps updatedAt and rewrites SKILL.md even when no field actually changed. Consider validating that at least one field is present (e.g., reject empty patches in the route, or express the constraint in the type) to avoid pointless DB writes and file rewrites.
| const data = await api.listSkills(); | ||
| if (refreshSeqRef.current === seq) setSkills(applyDesired(data)); |
There was a problem hiding this comment.
refreshSeqRef only guards refresh-vs-refresh races, not refresh-vs-mutation races. desiredRef is deleted as soon as the latest toggle response lands, so a refresh that started before a mutation but resolves after it will overwrite the correct state: the list request was served before the daemon processed the toggle, and applyDesired no longer has an overlay entry to correct it (UI shows the stale enabled value while the daemon holds the new one). The same hole lets a deleted skill reappear (a stale listSkills response replaces the whole array, undoing setSkills(prev => prev.filter(...))), and lets createSkill/updateSkill/importSkill results be clobbered until the next explicit refresh — exactly the class of bug this hook's comments claim to prevent. Consider a mutation epoch: bump a counter on every mutation and ignore refresh responses whose seq predates the latest mutation, or skip the setSkills write when any mutation committed while the request was in flight.
| const seqs = toggleSeqRef.current; | ||
| const seq = (seqs.get(id) ?? 0) + 1; | ||
| seqs.set(id, seq); | ||
| desiredRef.current.set(id, enabled); |
There was a problem hiding this comment.
toggleSeqRef entries are never removed for the hook's lifetime, and — more importantly — if an api.toggleSkill promise never settles (hanging network / daemon stuck in vault re-sync), its desiredRef entry is never deleted, so applyDesired keeps overlaying the stale optimistic value on every future refresh() indefinitely, leaving a permanently wrong enabled state for that skill. Suggest removing the seq entry once the toggle settles and guarding against never-settling promises (e.g., a timeout or cleanup on unmount) so a stale desiredRef can't persist a wrong state.
| * one line per field. Only the known skill keys count as boundaries — a value | ||
| * that happens to contain some other `word:` (e.g. "see: docs") stays intact. | ||
| */ | ||
| const COLLAPSED_FIELD = /[ \t]+(?=(?:name|description|version)\s*:)/; |
There was a problem hiding this comment.
COLLAPSED_FIELD splits any frontmatter line at whitespace preceding the literal substrings name:, description:, or version:. A legitimate value containing such text — e.g. a name My description: X or a description Prints the name: value pair — is split into two field lines, silently truncating the value and fabricating a spurious field. Because generateSkillMd emits these fields unquoted, one per line, the generate→parse round-trip relied on by store.ts writeSkillMd/updateSkill, the importer, and the web editor is not idempotent for such values. The existing test only guards unknown keys (see:), which never match this pattern. Suggestion: only apply the collapsed-field split when the line starts with a known field key AND the value segment before the boundary is plausibly short (no internal spaces), or quote values in generateSkillMd and skip splitting inside quoted values.
| function firstChars(text: string, n: number): string { | ||
| const chars = Array.from(text); | ||
| return singleLine(chars.slice(0, n).join('')); | ||
| } |
There was a problem hiding this comment.
The docstring states emoji "don't split mid-character", but Array.from iterates by Unicode code point, not grapheme cluster. ZWJ sequences (👨👩👧), flag emoji (regional-indicator pairs), and variation-selector sequences span multiple code points and will be cut mid-sequence, producing a broken auto-derived name. Use Intl.Segmenter with grapheme granularity to honor the documented guarantee.
Suggestion:
| function firstChars(text: string, n: number): string { | |
| const chars = Array.from(text); | |
| return singleLine(chars.slice(0, n).join('')); | |
| } | |
| function firstChars(text: string, n: number): string { | |
| const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); | |
| const graphemes = Array.from(segmenter.segment(text), (s) => s.segment); | |
| return singleLine(graphemes.slice(0, n).join('')); | |
| } |
bundled 技能(docling / wiki-* / remotion / wechat-article-extractor) 与 core 同等待遇:API 列表不可见、by-id 路由全部 404、生效集豁免 enabled 开关。这些技能被确定性链路依赖(KB 面板 wiki 操作、渠道意图 分流、docling preload + CLAUDE.md 硬规则),允许禁用会让功能入口还在 但 agent 找不到技能,静默坏掉。 - routes/skills.ts:list 过滤 bundled;GET/:id、PATCH、toggle、DELETE 对 bundled 返回 404;DELETE builtIn 文案去掉「可禁用」 - vault-config.ts:getEffectiveSkills = core || bundled || enabled - SkillsPanel:删徽章/编辑/删除守卫死代码;i18n 删 bundled/builtIn/ deleteConfirm key;settings.css 删 .sk-badge* - 自愈:旧安装中被禁用的 bundled 行无需迁移,启动 fan-out 自动同步回 各 vault(已在高数 vault 实测恢复) - E2E:gotoSkillsTab 改等 .sk-list/.sk-empty(库可能为空);bundled 可见性测试改断言隐藏;duplicate 改用自建技能源;删除清理超时 5s→15s (DELETE 等 14-vault fan-out ~5s)+ 重量级用例 test.slow()
Affected Areas & Test SelectionMatched areas:
Changed files: 57
Top changed symbols (by caller count):
E2E specs that will run on this PR:
Generated by Impact Analysis workflow. This is informational, not a gate. |
| // continue; skills sync on a later start once mount permissions are fixed. | ||
| try { | ||
| installSkills(defaultPath); | ||
| reconcileSkills(db, vault); |
There was a problem hiding this comment.
Startup-order issue: the default reconcileSkills is reconcileVault, which reads the skills table via getEffectiveSkills/listSkills. But maybeCreateDefaultVault runs during server.ts's module-body evaluation (triggered by index.ts's import ... from './server.js'), which happens BEFORE initSkillLibrary(db) seeds that table at index.ts line 99. On a fresh containerized first boot, this reconcile therefore sees an empty skill library: bundled skills (docling/wiki-*/remotion/wechat-article-extractor, which are always-on app-owned functionality) and gated CLAUDE.md rules are only installed later by the deferred reconcileAllVaultsAsync — and if seeding fails (or the daemon dies in that window), the vault is left without the bundled skills that installBuiltinSkills used to guarantee unconditionally. The old code read from the filesystem source dir and had no such DB dependency. Consider seeding the skill library before the module-level vault provisioning (e.g. move the maybeCreateDefaultVault call out of server.ts's module body, or seed within openDatabase/before it).
| const existing = getSkill(db, seed.id); | ||
| if (existing) { | ||
| refreshMeta(db, seed.id, seed.name, seed.description); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
refreshMeta only updates the DB row's name/description — it never regenerates the on-disk ~/.molio/skills/<id>/SKILL.md for core skills. Since core skills are hidden and not editable (routes/skills.ts 404s them), a future release that improves CORE_SKILLS_SEEDS will never reach existing installs: the DB row looks refreshed, but the file actually synced into every vault (sync.ts mirrors the content dir, whose frontmatter carries name/description and whose body carries the instructions) stays frozen at the first-seed version. This is asymmetric with bundled skills, whose content updates propagate live from app resources. Consider regenerating the core skill's SKILL.md when the seed changes (or version-gate the refresh).
| const existing = getSkill(db, slug); | ||
| if (existing) { | ||
| refreshMeta(db, slug, meta.name, meta.description); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Check-then-insert race: getSkill followed by createSkill is non-atomic, and createSkill uses a plain INSERT (id is the PRIMARY KEY). If two daemon processes seed concurrently (restart overlap, or a test suite sharing the DB file), both can read 'no row' and one will hit the UNIQUE constraint, throwing out of seedBuiltinSkills → initSkillLibrary returns false → the caller skips the entire per-vault fan-out for that start. Consider an upsert (INSERT OR IGNORE / ON CONFLICT DO NOTHING) followed by re-fetch, or wrap the seed in a transaction, so a duplicate seed is a no-op instead of a total-seed failure.
| export function mirrorDirIfChanged(srcDir: string, destDir: string): boolean { | ||
| if (isAlreadySynced(srcDir, destDir)) return false; |
There was a problem hiding this comment.
The swap sequence (copy → rename dest→bak → rename tmp→dest → rm bak) has no cross-process exclusion, yet the module explicitly anticipates other processes (see sweepStaleMirrorArtifacts' "another process is building right now" comment). Within one daemon, reconcileVault is synchronous so calls serialize, but two daemon instances can sync the same vault: overlapping startup where checkAndKillPortOccupant kills the old daemon while the new one begins its deferred fan-out, or two machines pointed at a NAS-backed vault. Interleaved renames can make one process's renameSync(destDir, backup) throw ENOENT (the other process already moved dest) → the mirror fails and its staging dir is discarded; worse, on POSIX a second process's renameSync(tmp, destDir) atomically replaces the dest the first process just swapped in — and the first process already deleted its backup — silently downgrading a freshly synced (possibly newer-upgrade) revision. Consider a per-vault lock file (open with 'wx' + stale takeover) or at least an existsSync(destDir) re-check immediately before the final rename, treating a moved/regained dest as a conflict.
| const cutoff = Date.now() - STALE_ARTIFACT_MS; | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory() || !MIRROR_ARTIFACT_RE.test(entry.name)) continue; |
There was a problem hiding this comment.
sweepStaleMirrorArtifacts judges staleness from the staging dir's mtime, but a directory's mtime is only bumped when entries are created/removed — NOT while a single large file's bytes stream in. A skill file can be up to ~100MB (the import cap) and copyDirSync copies it via copyFileSync; on slow NAS storage a copy can exceed STALE_ARTIFACT_MS (5 min), so a concurrent sweep (the multi-process case this module already anticipates) would rm -rf the in-flight .tmp-* dir mid-copy, failing the mirror with ENOENT and discarding the half-copied staging content instead of waiting. Consider a heartbeat (touch a marker file while copying) or requiring the artifact to be both older than the cutoff AND not modified-recently at deletion time.
| desiredRef.current.delete(id); | ||
| // Latest intent failed: re-sync from the server — it is the only | ||
| // authority on the actual state, so no blind flip-back guess. | ||
| void refresh(); |
There was a problem hiding this comment.
On a failed toggle, void refresh() synchronously sets loading=true and error=null, so the entire list is swapped out for a full-list spinner even though only a single toggle failed (SkillsPanel renders loading ? spinner : list), and the hook-level error is cleared. The error is only preserved because the caller rethrows it. Consider re-syncing without flipping loading (e.g., a background refresh that keeps the current list visible) so a single toggle failure doesn't cause a full-list loading flash.
| .sk-field__input:focus, | ||
| .sk-field__textarea:focus { | ||
| outline: none; | ||
| border-color: var(--accent); | ||
| } |
There was a problem hiding this comment.
The global input:focus, textarea:focus rule in base.css sets box-shadow: 0 0 0 3px var(--selected-soft) (blue ring). This local rule only overrides outline and border-color, so the --selected/--selected-soft blue ring from base.css still renders alongside the new coral --accent border — two different color systems fighting on the same focus state. The focus indicator is still visible (so it's not an a11y gap), but the mixed colors look inconsistent. Either keep the base focus (drop the local border-color override) or also set an accent-based ring for a coherent focus: box-shadow: 0 0 0 3px var(--accent-soft).
| return ( | ||
| `---\n` + | ||
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + |
There was a problem hiding this comment.
generateSkillMd interpolates name/description into the frontmatter without YAML escaping. A value containing : (e.g. a description like "保存为技能: 管理文件" or "Note: this skill..."), a leading #, or quotes produces frontmatter that strict YAML parsers (the runtime CLIs doing skill discovery, per the file's header) reject or misparse — the skill can silently fail to load or get a wrong name/description. singleLine only collapses newlines; it doesn't make the value a valid YAML scalar. Consider emitting values as double-quoted scalars (e.g. JSON.stringify, which is valid YAML for most strings) or otherwise escaping them.
Suggestion:
| return ( | |
| `---\n` + | |
| `name: ${singleLine(name)}\n` + | |
| `description: ${singleLine(description)}\n` + | |
| function yamlQuote(value: string): string { | |
| return JSON.stringify(singleLine(value)); | |
| } | |
| export function generateSkillMd(name: string, description: string, instructions: string): string { | |
| return ( | |
| `---\n` + | |
| `name: ${yamlQuote(name)}\n` + | |
| `description: ${yamlQuote(description)}\n` + |
| * one line per field. Only the known skill keys count as boundaries — a value | ||
| * that happens to contain some other `word:` (e.g. "see: docs") stays intact. | ||
| */ | ||
| const COLLAPSED_FIELD = /[ \t]+(?=(?:name|description|version)\s*:)/; |
There was a problem hiding this comment.
COLLAPSED_FIELD splits a line at any whitespace preceding a known key, even when that key text is genuinely part of the preceding value. E.g. description: 用法 name: 管理文件 is re-split into description: 用法 + name: 管理文件, truncating the description and creating a spurious name field. The existing tests only cover non-known keys (see:) inside values, so this false positive is untested. Consider restricting the split (e.g. only treat it as a collapsed field when the preceding segment is short/plausible for the previous field) or at least documenting this trade-off.
| let i = 0; | ||
| const fieldLines: string[] = []; | ||
| while (i < lines.length) { | ||
| const line = lines[i] ?? ''; | ||
| if (!FIELD_LINE.test(line)) break; // a blank line or body text ends the unfenced block |
There was a problem hiding this comment.
In the unfenced path, the consume loop tests every contiguous line against FIELD_LINE (^word:), not just the known skill keys, even though the block is only entered when the FIRST line is a known field. So a body that starts immediately with a word: line and no blank separator (e.g. name: x\ndescription: d\nNote: 正文第一行) has that body line silently swallowed into the frontmatter and dropped from the instructions. Gating the loop on KNOWN_FIELD (name/description/version) instead of FIELD_LINE would keep only real skill fields in the frontmatter and leave word: body lines intact.
重构 277367c 把 bundled 技能隐藏后,技能库在 CI 里可能为空,空态容器渲染的 是共享的 .rt-empty —— .sk-empty 这个 class 从不存在(git log -S 查无)。此前 bundled 可见使库恒非空、.sk-list 一直兜住了这个笔误;bundled 隐藏后 8 个用例 全卡在 gotoSkillsTab 等 .sk-list/.sk-empty 超时。 改等 .sk-list, .rt-empty 并加注释说明。本地 skills.spec 8/8 通过(2.0m)。
| const walk = (d: string): void => { | ||
| for (const entry of fs.readdirSync(d, { withFileTypes: true })) { |
There was a problem hiding this comment.
fs.readdirSync(d, ...) in the recursive walk has no error handling. If a subdirectory is unreadable (EACCES/EPERM on permissioned dirs, broken mounts) or disappears mid-walk, a raw Node fs error propagates out of assertFolderWithinLimits and, since it isn't a SkillImportError, the route converts it to a generic 500 instead of a friendly 400. Wrap the readdirSync in try/catch and rethrow as SkillImportError, mirroring how the statSync below already degrades gracefully.
Suggestion:
| const walk = (d: string): void => { | |
| for (const entry of fs.readdirSync(d, { withFileTypes: true })) { | |
| const walk = (d: string): void => { | |
| let entries: fs.Dirent[]; | |
| try { | |
| entries = fs.readdirSync(d, { withFileTypes: true }); | |
| } catch { | |
| throw new SkillImportError('BAD_REQUEST', `无法读取子目录:${d}`); | |
| } | |
| for (const entry of entries) { |
| } | ||
| let size = 0; | ||
| try { | ||
| size = fs.statSync(p).size; // follows symlinks, like copyDirSync does |
There was a problem hiding this comment.
The comment claims statSync follows symlinks "like copyDirSync does", but copyDirSync (dirsync.ts) actually SKIPS symlinks (entry.isSymbolicLink() → skip). Because of this mismatch, a directory whose root SKILL.md is a symlink passes every check here — existsSync, statSync, and readFileSync all follow the link — yet the symlink is omitted during the copy. The imported skill's content dir then ends up with no SKILL.md, producing a skill that syncs to vaults without a discoverable SKILL.md (broken import). Consider rejecting symlinks during the walk, or copying the resolved target so the guard and the copy stay consistent.
Suggestion:
| size = fs.statSync(p).size; // follows symlinks, like copyDirSync does | |
| size = fs.statSync(p).size; // follows symlinks; copyDirSync skips symlinks, so reject them here |
| id !== '.' && | ||
| id !== '..' && | ||
| !id.includes('/') && | ||
| !id.includes('\\') && | ||
| id === path.basename(id) |
There was a problem hiding this comment.
The path-traversal guard rejects /, \, and exact ./.., but on Windows a trailing dot or trailing space is silently stripped by the Win32 filesystem, so IDs like .. , ..., or a. normalize to .., ..., a once interpolated into a path. Since this module is the single owner of the path layout (skillContentDir/molioSkillDir feed mkdirSync/writeFileSync/rmSync), closing the gap here — e.g. rejecting ids that end with a dot/space or a trailing sequence of dots, and (for completeness) Windows-reserved names (CON, NUL, COM1, …) and chars < > : " | ? * — keeps the invariant airtight even if a future caller (route param, imported filename) feeds it user-controlled ids. Currently the only reachable ids are UUIDs and hardcoded slugs, so this is defense-in-depth, not an active exploit.
Suggestion:
| id !== '.' && | |
| id !== '..' && | |
| !id.includes('/') && | |
| !id.includes('\\') && | |
| id === path.basename(id) | |
| id !== '.' && | |
| id !== '..' && | |
| !/[\. ]$/.test(id) && | |
| !id.includes('/') && | |
| !id.includes('\\') && | |
| id === path.basename(id) |
| // continue; skills sync on a later start once mount permissions are fixed. | ||
| try { | ||
| installSkills(defaultPath); | ||
| reconcileSkills(db, vault); |
There was a problem hiding this comment.
Ordering hazard: maybeCreateDefaultVault runs at module scope of server.ts, which ESM evaluates before index.ts's top-level initSkillLibrary(db) (index.ts imports server.js). So this provisioning-time reconcile reads an UNSEEDED (empty) skills table on first boot. With an empty table, reconcileVault → reconcileSync([]) performs orphan cleanup and treats every existing molio--<id> dir as an orphan. In the exact deployment this feature targets — persistent /vaults NAS mount + ephemeral container DB (DB wiped on recreate, mount preserved) — previously synced library/core skills get DELETED here before the table is seeded, then re-created only if the deferred reconcileAllVaultsAsync runs and seeding succeeded. index.ts's own comment establishes the invariant "seeding must run before any vault reconcile reads the table" — this path violates it. Suggest seeding the skills table before provisioning (or skipping the reconcile here when the table is empty).
| try { | ||
| fs.renameSync(backup, destDir); | ||
| } catch { | ||
| /* nothing more we can do — throw the original error below */ | ||
| } |
There was a problem hiding this comment.
Failed-rollback path can permanently lose the deployed skill. When the final rename(tmp, destDir) fails and the restore rename(backup, destDir) ALSO fails (the inner catch swallows the restore error and rethrows the original), the old content stays parked under destDir.bak-*. That backup name matches MIRROR_ARTIFACT_RE and is handed to sweepStaleMirrorArtifacts, which will delete it after 5 minutes — so a transient EPERM/AV-lock failure converts the intended 'old content still present' degradation into 'skill dir gone'. The restore failure should at minimum be surfaced/logged distinctly, and the orphaned backup should be preserved (e.g. excluded from the sweep) rather than being eligible for deletion.
| export function generateSkillMd(name: string, description: string, instructions: string): string { | ||
| return ( | ||
| `---\n` + | ||
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + | ||
| `version: 1.0.0\n` + | ||
| `---\n\n` + | ||
| `${instructions.trim()}\n` | ||
| ); | ||
| } |
There was a problem hiding this comment.
generateSkillMd inserts name/description into YAML frontmatter without quoting or escaping. A value that is a bare block-scalar indicator (|, >, |-, >+) or starts with one is emitted as e.g. description: | and re-parsed as an empty block scalar — the value is lost entirely on round-trip. Values wrapped in quotes ("...") are also stripped by the parser's quote-removal. Since the web editor saves through this function, editing a skill whose name/description contains such characters silently corrupts its metadata. Consider quoting YAML values (and escaping embedded quotes) or rejecting block-scalar indicator values at generation time.
Suggestion:
| export function generateSkillMd(name: string, description: string, instructions: string): string { | |
| return ( | |
| `---\n` + | |
| `name: ${singleLine(name)}\n` + | |
| `description: ${singleLine(description)}\n` + | |
| `version: 1.0.0\n` + | |
| `---\n\n` + | |
| `${instructions.trim()}\n` | |
| ); | |
| } | |
| export function generateSkillMd(name: string, description: string, instructions: string): string { | |
| return ( | |
| `---\n` + | |
| `name: ${singleLine(name)}\n` + | |
| `description: ${singleLine(description)}\n` + | |
| `version: 1.0.0\n` + | |
| `---\n\n` + | |
| `${instructions.trim()}\n` | |
| ); | |
| } |
| `---\n` + | ||
| `name: ${singleLine(name)}\n` + | ||
| `description: ${singleLine(description)}\n` + | ||
| `version: 1.0.0\n` + |
There was a problem hiding this comment.
version: 1.0.0 is hardcoded on every generated SKILL.md, and parseSkillMd never reads the version field. Editing an imported skill that carries a different version (e.g. version: 2.3.0) resets it to 1.0.0, and the version is silently dropped from the parsed result. The ParsedSkillMd interface has no version field, so version information can never round-trip. If version is meaningful for vault-installed skills (as the field's presence in the format suggests), consider parsing and preserving it rather than unconditionally rewriting it.
Suggestion:
| `version: 1.0.0\n` + | |
| `version: 1.0.0\n` + |
| let inFence = false; | ||
| for (const line of body.split(/\r?\n/)) { | ||
| if (/^\s*(```|~~~)/.test(line)) { | ||
| inFence = !inFence; | ||
| continue; | ||
| } | ||
| if (inFence) continue; |
There was a problem hiding this comment.
firstHeadingText toggles the fence state on ANY line starting with triple backticks/tildes, with no check that the fence is properly opened/closed. An unclosed fence (common in pasted snippets) or a stray fence line in prose leaves the state inverted, so genuine headings are skipped — or a code-comment line (# comment inside a fence) is selected as the skill name instead. The resulting fallback name is wrong and the failure is silent. Consider tracking the fence's info-string/backtick count so only a matching closing fence toggles the state, and treat an unclosed fence as terminating at end of input.
Suggestion:
| let inFence = false; | |
| for (const line of body.split(/\r?\n/)) { | |
| if (/^\s*(```|~~~)/.test(line)) { | |
| inFence = !inFence; | |
| continue; | |
| } | |
| if (inFence) continue; | |
| let inFence = false; | |
| for (const line of body.split(/\r?\n/)) { | |
| if (/^\s*(```|~~~)/.test(line)) { | |
| inFence = !inFence; | |
| continue; | |
| } | |
| if (inFence) continue; |
| /* A skill that's globally disabled: greyed out, switch locked off (per-vault modal). */ | ||
| .sk-row--off { | ||
| opacity: 0.55; | ||
| } | ||
|
|
||
| .sk-row--off .sk-switch { | ||
| cursor: not-allowed; | ||
| } |
There was a problem hiding this comment.
This .sk-row--off state is dead CSS — searching the entire codebase shows the class is never applied by any component (SkillsPanel.tsx always renders .sk-row without the --off modifier). The comment describes a 'switch locked off (per-vault modal)' behavior that isn't wired up anywhere in this change set. Either implement the locked-off state in the component that renders the rows, or remove these rules to avoid dead styles that imply behavior which doesn't exist.
| .sk-field--grow .sk-field__textarea { | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| resize: none; | ||
| } |
There was a problem hiding this comment.
.sk-field--grow .sk-field__textarea (specificity 0,2,0) sets min-height: 0, which silently overrides the min-height: 160px from .sk-field__textarea--md (specificity 0,1,0) on the instructions textarea in the fullscreen editor (it carries both classes). On short viewports the grow textarea can shrink to a near-zero height, making the main authoring surface unusable. If the 160px minimum is still desired for usability in the fullscreen layout, keep a floor (e.g. min-height: 160px in the grow rule) or drop the --md class there to make the intent explicit.
Summary
utils/skillmd.ts解析/序列化,镜像 daemon 格式)。createSkill以sourceDir原样拷贝整棵目录,sync镜像整个内容目录(含 references/scripts 同级文件),目标目录每次重建避免残留旧文件。showSkillFilePicker(.md 文件选择器);浏览器 / NAS 无原生选择器时降级为手填容器路径。Test plan
pnpm --filter @molio/web typecheck通过pnpm desktop:run重新打包成功(win-unpacked)cd apps/web && npx playwright test e2e/skills.spec.ts(需先pnpm dev)pnpm --filter @molio/daemon test