SLOPSHOPPER

agents-sync

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

newtoastprocess
★ 1v0.1.0no licenseupdated 2026-10-04turanmahmudov/dotfiles/.claude/skills/agents-sync
A shopper browsing a rack in a slop shop
README

Dotfiles

My personal dotfiles managed with YADM.

Tested on Ubuntu 25.10 and Ubuntu 25.04.

Installation

yadm clone <your-repo-url>
yadm config local.class work  # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap

Important Keybindings

  • SUPER + Return - Terminal
  • SUPER + T - Terminal with tmux
  • SUPER + Space/D - Application launcher
Source 2 files
hooks/register.ts 88 lines
1import 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}
88
hooks/plan.ts 42 lines
1export 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