Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/cache/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { CachedDoc, CachedPackage } from './types.ts'
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { basename, join, resolve } from 'pathe'
import { readPackageJsonSafe } from '../core/package-json.ts'
import { resolvePkgDir } from '../core/prepare.ts'
import { sanitizeMarkdown } from '../core/sanitize.ts'
import { getRepoCacheDir, REFERENCES_DIR, REPOS_DIR } from './config.ts'
Expand Down Expand Up @@ -189,8 +190,9 @@ export function getPkgKeyFiles(name: string, cwd: string, version?: string): str
const files: string[] = []
const pkgJsonPath = join(pkgPath, 'package.json')

if (existsSync(pkgJsonPath)) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
const pkgJsonResult = readPackageJsonSafe(pkgJsonPath)
if (pkgJsonResult) {
const pkg = pkgJsonResult.parsed as Record<string, any>

// Entry points
if (pkg.main)
Expand Down
27 changes: 18 additions & 9 deletions src/cli-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@

import type { AgentType, OptimizeModel } from './agent/index.ts'
import type { ProjectState } from './core/skills.ts'
import { existsSync, readFileSync } from 'node:fs'
import * as p from '@clack/prompts'
import { parseTree } from 'jsonc-parser'
import { join } from 'pathe'
import { detectCurrentAgent } from 'unagent/env'
import { agents, detectInstalledAgents, detectProjectAgents, detectTargetAgent, getAgentVersion, getModelName } from './agent/index.ts'
import { readConfig, updateConfig } from './core/config.ts'
import { editJsonProperty, patchPackageJson } from './core/package-json.ts'
import { editJsonProperty, patchPackageJson, readPackageJsonSafe } from './core/package-json.ts'
import { version } from './version.ts'

export type { AgentType, OptimizeModel }
Expand Down Expand Up @@ -446,14 +445,24 @@ export async function pickModel<T extends { provider: string, providerName: stri
* In non-interactive environments, falls back to an info log.
* Returns true if the hook was added or already present.
*/
/**
* Check if the prepare hook is already installed in package.json.
*/
export function hasPrepareHook(cwd: string = process.cwd()): boolean {

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two adjacent JSDoc blocks here: the first describes suggestPrepareHook, but it now sits above hasPrepareHook, and the second immediately starts a new block. This makes the doc association ambiguous/incorrect (and can confuse doc generators/lint rules). Consider moving the β€œPrompt to add …” block to suggestPrepareHook (or merging into a single block with separate docs for each function).

Copilot uses AI. Check for mistakes.
const pkg = readPackageJsonSafe(join(cwd, 'package.json'))
if (!pkg)
return true // no package.json means nothing to suggest
const existing = (pkg.parsed.scripts as Record<string, unknown> | undefined)?.prepare
return typeof existing === 'string' && existing.includes('skilld')
}

export async function suggestPrepareHook(cwd: string = process.cwd()): Promise<boolean> {
const pkgJsonPath = join(cwd, 'package.json')
if (!existsSync(pkgJsonPath))
const pkg = readPackageJsonSafe(pkgJsonPath)
if (!pkg)
return false

const raw = readFileSync(pkgJsonPath, 'utf-8')
const pkgJson = JSON.parse(raw)
const rawExisting = pkgJson.scripts?.prepare
const rawExisting = (pkg.parsed.scripts as Record<string, unknown> | undefined)?.prepare
const existing: string | undefined = typeof rawExisting === 'string' ? rawExisting : undefined

if (existing?.includes('skilld'))
Expand Down Expand Up @@ -512,10 +521,10 @@ export function buildPrepareScript(existing: string | undefined): string {
}

export function getRepoHint(name: string, cwd: string): string | undefined {
const pkgJsonPath = join(cwd, 'node_modules', name, 'package.json')
if (!existsSync(pkgJsonPath))
const result = readPackageJsonSafe(join(cwd, 'node_modules', name, 'package.json'))
if (!result)
return undefined
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
const pkg = result.parsed as Record<string, any>
const url = typeof pkg.repository === 'string'
? pkg.repository
: pkg.repository?.url
Expand Down
26 changes: 20 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { defineCommand, runMain } from 'citty'
import pLimit from 'p-limit'
import { join, resolve } from 'pathe'
import { agents, detectImportedPackages, detectInstalledAgents } from './agent/index.ts'
import { formatStatus, getRepoHint, guard, isInteractive, isRunningInsideAgent, menuLoop, promptForAgent, relativeTime, resolveAgent, sharedArgs, suggestPrepareHook } from './cli-helpers.ts'
import { formatStatus, getRepoHint, guard, hasPrepareHook, isInteractive, isRunningInsideAgent, menuLoop, promptForAgent, relativeTime, resolveAgent, sharedArgs, suggestPrepareHook } from './cli-helpers.ts'
import { configCommand, configCommandDef } from './commands/config.ts'
import { removeCommand, removeCommandDef } from './commands/remove.ts'
import { infoCommandDef, statusCommand } from './commands/status.ts'
import { runWizard } from './commands/wizard.ts'
import { timedSpinner } from './core/formatting.ts'
import { getProjectState, hasCompletedWizard, isOutdated, readConfig, semverGt } from './core/index.ts'
import { readPackageJsonSafe } from './core/package-json.ts'
import { iterateSkills } from './core/skills.ts'
import { fetchLatestVersion, fetchNpmRegistryMeta } from './sources/index.ts'

Expand Down Expand Up @@ -293,10 +294,9 @@ const main = defineCommand({

// Transition to project setup
const pkgJsonPath = join(cwd, 'package.json')
const hasPkgJson = existsSync(pkgJsonPath)
const projectName = hasPkgJson
? JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name
: undefined
const projectPkg = readPackageJsonSafe(pkgJsonPath)
const hasPkgJson = !!projectPkg
const projectName = projectPkg?.parsed.name as string | undefined
const projectLabel = projectName
? `Generating skills for \x1B[36m${projectName}\x1B[0m`
: 'Generating skills for current directory'
Expand Down Expand Up @@ -527,6 +527,11 @@ const main = defineCommand({
const status = formatStatus(state.synced.length, state.outdated.length)
p.log.info(status)

let needsPrepareHook = !hasPrepareHook(cwd)
if (needsPrepareHook) {
p.log.warn(`\x1B[33mNo prepare hook.\x1B[0m Skills won't auto-restore on \x1B[36mnpm install\x1B[0m.`)
}

if (state.shipped.length > 0) {
const totalSkills = state.shipped.reduce((sum, s) => sum + s.skills.length, 0)
const names = state.shipped.map(s => s.packageName).join(', ')
Expand All @@ -550,6 +555,9 @@ const main = defineCommand({
if (state.outdated.length > 0) {
opts.push({ label: 'Update skills', value: 'update', hint: `\x1B[33m${state.outdated.length} outdated\x1B[0m` })
}
if (needsPrepareHook) {
opts.push({ label: 'Setup prepare hook', value: 'prepare-hook', hint: '\x1B[33mrecommended\x1B[0m' })
}
opts.push(
{ label: 'Remove skills', value: 'remove' },
{ label: 'Search docs', value: 'search' },
Expand Down Expand Up @@ -593,7 +601,7 @@ const main = defineCommand({
].filter(Boolean) as string[])
const uninstalledDeps = [...state.deps.keys()].filter(d => !installedNames.has(d))
const allDepsInstalled = uninstalledDeps.length === 0
const hasPkgJsonMenu = existsSync(join(cwd, 'package.json'))
const hasPkgJsonMenu = !!readPackageJsonSafe(join(cwd, 'package.json'))

const source = hasPkgJsonMenu
? guard(await p.select({
Expand Down Expand Up @@ -758,6 +766,12 @@ const main = defineCommand({
await configCommand()
await refreshState()
break
case 'prepare-hook': {
const added = await suggestPrepareHook(cwd)
if (added)
needsPrepareHook = false
break
}
}
},
})
Expand Down
22 changes: 11 additions & 11 deletions src/commands/author.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { guard } from '../cli-helpers.ts'
import { defaultFeatures, readConfig } from '../core/config.ts'
import { timedSpinner } from '../core/formatting.ts'
import { appendToJsonArray, patchPackageJson } from '../core/package-json.ts'
import { appendToJsonArray, patchPackageJson, readPackageJsonSafe } from '../core/package-json.ts'
import { sanitizeMarkdown } from '../core/sanitize.ts'
import {
fetchGitHubDiscussions,
Expand Down Expand Up @@ -55,11 +55,11 @@ export interface MonorepoPackage {
}

export function detectMonorepoPackages(cwd: string): MonorepoPackage[] | null {
const pkgPath = join(cwd, 'package.json')
if (!existsSync(pkgPath))
const rootResult = readPackageJsonSafe(join(cwd, 'package.json'))
if (!rootResult)
return null

const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
const pkg = rootResult.parsed as Record<string, any>

// Must be private (monorepo root) with workspaces or pnpm-workspace.yaml
if (!pkg.private)
Expand Down Expand Up @@ -105,11 +105,11 @@ export function detectMonorepoPackages(cwd: string): MonorepoPackage[] | null {
for (const entry of readdirSync(scanDir, { withFileTypes: true })) {
if (!entry.isDirectory())
continue
const pkgJsonPath = join(scanDir, entry.name, 'package.json')
if (!existsSync(pkgJsonPath))
const childResult = readPackageJsonSafe(join(scanDir, entry.name, 'package.json'))
if (!childResult)
continue

const childPkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
const childPkg = childResult.parsed as Record<string, any>
if (childPkg.private)
continue
if (!childPkg.name)
Expand Down Expand Up @@ -532,11 +532,11 @@ async function authorCommand(opts: {
const llmConfig = await resolveLlmConfig(opts.model, opts.yes)

// Resolve monorepo-level repoUrl for packages that lack their own
const rootPkgPath = join(cwd, 'package.json')
const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8'))
const rootRepoUrl = typeof rootPkg.repository === 'string'
const rootPkgResult = readPackageJsonSafe(join(cwd, 'package.json'))
const rootPkg = rootPkgResult?.parsed as Record<string, any> | undefined
const rootRepoUrl = typeof rootPkg?.repository === 'string'
? rootPkg.repository
: rootPkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '')
: rootPkg?.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '')

const results: Array<{ name: string, outDir: string }> = []

Expand Down
7 changes: 4 additions & 3 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { promptForAgent, resolveAgent, sharedArgs } from '../cli-helpers.ts'
import { defaultFeatures, readConfig } from '../core/config.ts'
import { timedSpinner } from '../core/formatting.ts'
import { mergeLocks, parsePackages, readLock, syncLockfilesToDirs, writeLock } from '../core/lockfile.ts'
import { readPackageJsonSafe } from '../core/package-json.ts'
import { sanitizeMarkdown } from '../core/sanitize.ts'
import { getSharedSkillsDir } from '../core/shared.ts'
import { createIndex, SearchDepsUnavailableError } from '../retriv/index.ts'
Expand Down Expand Up @@ -578,9 +579,9 @@ async function enhanceRegenerated(
let description: string | undefined
if (pkgPath) {
const pkgJsonPath = join(pkgPath, 'package.json')
if (existsSync(pkgJsonPath)) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
description = pkg.description
const pkgJsonResult = readPackageJsonSafe(pkgJsonPath)
if (pkgJsonResult) {
description = pkgJsonResult.parsed.description as string | undefined
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/commands/sync-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { isInteractive, NO_MODELS_MESSAGE, pickModel } from '../cli-helpers.ts'
import { defaultFeatures, readConfig, registerProject, updateConfig } from '../core/config.ts'
import { parsePackages, readLock, writeLock } from '../core/lockfile.ts'
import { parseFrontmatter } from '../core/markdown.ts'
import { readPackageJsonSafe } from '../core/package-json.ts'
import { sanitizeMarkdown } from '../core/sanitize.ts'
import { getSharedSkillsDir, semverDiff } from '../core/shared.ts'
import { createIndex, listIndexIds, SearchDepsUnavailableError } from '../retriv/index.ts'
Expand Down Expand Up @@ -264,12 +265,12 @@ export function resolveBaseDir(cwd: string, agent: AgentType, global: boolean):

/** Try resolving a `link:` dependency to local package docs. Returns null if not a link dep or resolution fails. */
export async function resolveLocalDep(packageName: string, cwd: string): Promise<ResolvedPackage | null> {
const pkgPath = join(cwd, 'package.json')
if (!existsSync(pkgPath))
const result = readPackageJsonSafe(join(cwd, 'package.json'))
if (!result)
return null

const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
const pkg = result.parsed
const deps = { ...pkg.dependencies as Record<string, string>, ...pkg.devDependencies as Record<string, string> }
const depVersion = deps[packageName]

if (!depVersion?.startsWith('link:'))
Expand Down
6 changes: 6 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface SkilldConfig {
const CONFIG_DIR = join(homedir(), '.skilld')
const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml')

let configCache: SkilldConfig | undefined

export function hasConfig(): boolean {
return existsSync(CONFIG_PATH)
}
Expand All @@ -42,6 +44,8 @@ export function hasCompletedWizard(): boolean {
}

export function readConfig(): SkilldConfig {
if (configCache)
return configCache
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (!existsSync(CONFIG_PATH))
return {}

Expand Down Expand Up @@ -93,6 +97,7 @@ export function readConfig(): SkilldConfig {
config.projects = projects
if (Object.keys(features).length > 0)
config.features = { ...defaultFeatures, ...features }
configCache = config
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return config
}

Expand Down Expand Up @@ -120,6 +125,7 @@ export function writeConfig(config: SkilldConfig): void {
}

writeFileSync(CONFIG_PATH, yaml, { mode: 0o600 })
configCache = undefined
}

export function updateConfig(updates: Partial<SkilldConfig>): void {
Expand Down
20 changes: 19 additions & 1 deletion src/core/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,19 @@ export function parseSkillFrontmatter(skillPath: string): SkillInfo | null {
return info
}

const lockCache = new Map<string, SkilldLock>()

export function invalidateLockCache(skillsDir?: string): void {
if (skillsDir)
lockCache.delete(skillsDir)
else
lockCache.clear()
}

export function readLock(skillsDir: string): SkilldLock | null {
const cached = lockCache.get(skillsDir)
if (cached)
return cached
const lockPath = join(skillsDir, 'skilld-lock.yaml')
if (!existsSync(lockPath))
return null
Expand All @@ -84,7 +96,9 @@ export function readLock(skillsDir: string): SkilldLock | null {
skills[currentSkill]![kv[0]] = kv[1]
}
}
return { skills }
const lock = { skills }
lockCache.set(skillsDir, lock)
return lock
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function serializeLock(lock: SkilldLock): string {
Expand Down Expand Up @@ -137,6 +151,7 @@ export function writeLock(skillsDir: string, skillName: string, info: SkillInfo)

lock.skills[skillName] = info
writeFileSync(lockPath, serializeLock(lock))
invalidateLockCache(skillsDir)
}

/**
Expand Down Expand Up @@ -169,6 +184,7 @@ export function syncLockfilesToDirs(sourceLock: SkilldLock, dirs: string[]): voi
// Merge source into existing
const merged = mergeLocks([existing, sourceLock])
writeFileSync(lockPath, serializeLock(merged))
invalidateLockCache(dir)
}
}

Expand All @@ -182,8 +198,10 @@ export function removeLockEntry(skillsDir: string, skillName: string): void {

if (Object.keys(lock.skills).length === 0) {
unlinkSync(lockPath)
invalidateLockCache(skillsDir)
return
}

writeFileSync(lockPath, serializeLock(lock))
invalidateLockCache(skillsDir)
}
Loading
Loading