Links the shared skills from ~/.agents/skills into ~/.claude/skills at session start

My personal dotfiles managed with YADM.
Tested on Ubuntu 25.10 and Ubuntu 25.04.
yadm clone <your-repo-url>
yadm config local.class work # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap
SUPER + Return - TerminalSUPER + T - Terminal with tmuxSUPER + Space/D - Application launcherhooks/register.ts 88 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { planSync } from './plan'
4import type { LinkEntry, LinkSource } from './plan'
5
6const SOURCE_KINDS = [{ dir: '.agents/skills', marker: 'SKILL.md' }]
7
8export const register: Register = on => {
9 on('session.start', async ($, e, next) => {
10 const result = await next(e)
11 const summary = await syncLinks($).catch(error => `agents-sync failed: ${error instanceof Error ? error.message : String(error)}`)
12 if (summary !== '') $.ui.toast(summary, { timeoutMs: 8_000 })
13
14 return result
15 })
16}
17
18async function syncLinks($: EngineInterface): Promise<string> {
19 const home = await $.env.get('HOME')
20 if (home === undefined || home === '') return 'agents-sync: HOME is not set.'
21
22 const ownRoot = `${home}/.agents`
23 const linkRoot = `${home}/.claude/skills`
24 const sources = await findSources($, home)
25 const entries = await findEntries($, linkRoot)
26 const actions = planSync(sources, entries, ownRoot, linkRoot)
27
28 let linked = 0
29 let removed = 0
30 const conflicts: string[] = []
31 for (const action of actions) {
32 if (action.kind === 'conflict') {
33 conflicts.push(action.name)
34 continue
35 }
36 const argv = action.kind === 'link' ? ['ln', '-s', action.source, action.link] : ['unlink', action.link]
37 const ran = await $.process.run(argv, { timeoutMs: 5_000 })
38 if (ran.exitCode !== 0) {
39 conflicts.push(`${action.name} (${ran.stderr.trim()})`)
40 continue
41 }
42 if (action.kind === 'link') linked += 1
43 else removed += 1
44 }
45
46 const parts = [
47 linked > 0 ? `linked ${linked}` : '',
48 removed > 0 ? `removed ${removed}` : '',
49 conflicts.length > 0 ? `skipped ${conflicts.join(', ')}: a real folder or a foreign link is in the way` : '',
50 ].filter(part => part !== '')
51 if (parts.length === 0) return ''
52
53 return `agents-sync: ${parts.join(', ')}.${linked > 0 ? ' New links load in the next session.' : ''}`
54}
55
56async function findSources($: EngineInterface, home: string): Promise<LinkSource[]> {
57 const sources: LinkSource[] = []
58 for (const kind of SOURCE_KINDS) {
59 const root = `${home}/${kind.dir}`
60 const listed = await $.fs.list(root).catch(() => [])
61 for (const entry of listed) {
62 if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
63 if (await $.fs.exists(`${root}/${entry.name}/${kind.marker}`)) sources.push({ name: entry.name, path: `${root}/${entry.name}` })
64 }
65 }
66
67 return sources
68}
69
70async function findEntries($: EngineInterface, linkRoot: string): Promise<LinkEntry[]> {
71 const listed = await $.fs.list(linkRoot).catch(() => [])
72 const entries: LinkEntry[] = []
73 for (const entry of listed) {
74 const path = `${linkRoot}/${entry.name}`
75 const read = await $.process.run(['readlink', path], { timeoutMs: 5_000 })
76 const isLink = read.exitCode === 0
77 entries.push({
78 name: entry.name,
79 path,
80 isLink,
81 linkTarget: isLink ? read.stdout.trim() : undefined,
82 isTargetPresent: isLink ? await $.fs.exists(read.stdout.trim()) : true,
83 })
84 }
85
86 return entries
87}
88hooks/plan.ts 42 lines1export type LinkSource = { name: string; path: string }
2
3export type LinkEntry = { name: string; path: string; isLink: boolean; linkTarget?: string; isTargetPresent: boolean }
4
5export type SyncAction =
6 | { kind: 'link'; name: string; source: string; link: string }
7 | { kind: 'unlink'; name: string; link: string }
8 | { kind: 'conflict'; name: string; link: string }
9
10export function planSync(sources: readonly LinkSource[], entries: readonly LinkEntry[], ownRoot: string, linkRoot: string): SyncAction[] {
11 const actions: SyncAction[] = []
12 const byName = new Map(entries.map(entry => [entry.name, entry]))
13
14 for (const source of sources) {
15 const entry = byName.get(source.name)
16 const link = `${linkRoot}/${source.name}`
17 if (entry === undefined) actions.push({ kind: 'link', name: source.name, source: source.path, link })
18 else if (!entry.isLink) actions.push({ kind: 'conflict', name: source.name, link })
19 else if (entry.linkTarget !== source.path) {
20 if (isOwned(entry, ownRoot)) {
21 actions.push({ kind: 'unlink', name: source.name, link })
22 actions.push({ kind: 'link', name: source.name, source: source.path, link })
23 } else {
24 actions.push({ kind: 'conflict', name: source.name, link })
25 }
26 }
27 }
28
29 const wanted = new Set(sources.map(source => source.name))
30 for (const entry of entries) {
31 if (entry.isLink && !wanted.has(entry.name) && isOwned(entry, ownRoot) && !entry.isTargetPresent) {
32 actions.push({ kind: 'unlink', name: entry.name, link: entry.path })
33 }
34 }
35
36 return actions
37}
38
39function isOwned(entry: LinkEntry, ownRoot: string): boolean {
40 return entry.linkTarget !== undefined && entry.linkTarget.startsWith(`${ownRoot}/`)
41}
42