@yuchenrx/pmt-cli 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import omelette from 'omelette'
2
+ import { getCliName } from './cli-name'
3
+ import { loadMergedConfig } from './config'
4
+ import { listFiles } from './helpers'
5
+ import { loadPromptRoot } from './prompt-root'
6
+
7
+ const commands = [
8
+ 'init',
9
+ 'set',
10
+ 'use',
11
+ 'list',
12
+ 'insert',
13
+ 'apply',
14
+ 'link',
15
+ 'search',
16
+ 'open',
17
+ 'open-config',
18
+ 'status',
19
+ ]
20
+
21
+ const scopes = ['project', 'global']
22
+
23
+ const listPromptNames = async () => {
24
+ const info = await loadPromptRoot()
25
+ if (!info) return []
26
+ const files = await listFiles(info.root)
27
+ return files.sort()
28
+ }
29
+
30
+ const listSourceKeys = async () => {
31
+ const merged = await loadMergedConfig()
32
+ if (!merged.hasConfig) return []
33
+ return Object.keys(merged.resolvedSources).sort()
34
+ }
35
+
36
+ const hasCommand = (line: string, command: string) => line.trim().includes(` ${command}`)
37
+
38
+ const createCompletion = () => {
39
+ const cliName = getCliName()
40
+ const completion = omelette(`${cliName} <command> <arg1> <arg2> <arg3>`)
41
+
42
+ completion.on('command', ({ reply }) => {
43
+ reply(commands)
44
+ })
45
+
46
+ completion.on('arg1', async ({ line, reply }) => {
47
+ if (hasCommand(line, 'link')) {
48
+ reply(await listPromptNames())
49
+ return
50
+ }
51
+
52
+ if (hasCommand(line, 'set') || hasCommand(line, 'use')) {
53
+ reply(await listSourceKeys())
54
+ return
55
+ }
56
+
57
+ if (hasCommand(line, 'open') || hasCommand(line, 'open-config')) {
58
+ reply(scopes)
59
+ return
60
+ }
61
+
62
+ })
63
+
64
+
65
+ completion.on('arg2', ({ line, reply }) => {
66
+ if (hasCommand(line, 'set') || hasCommand(line, 'use') || hasCommand(line, 'link')) {
67
+ reply(scopes)
68
+ }
69
+ })
70
+
71
+ completion.init()
72
+ return completion
73
+ }
74
+
75
+ let completionInstance: ReturnType<typeof createCompletion> | null = null
76
+
77
+ const getCompletion = () => {
78
+ if (!completionInstance) {
79
+ completionInstance = createCompletion()
80
+ }
81
+ return completionInstance
82
+ }
83
+
84
+ export const initCompletion = () => {
85
+ getCompletion()
86
+ }
87
+
88
+ export const installCompletion = () => {
89
+ const completion = getCompletion()
90
+ completion.setupShellInitFile()
91
+ }
92
+
93
+ export const cleanupCompletion = () => {
94
+ const completion = getCompletion()
95
+ completion.cleanupShellInitFile()
96
+ }
package/src/config.ts ADDED
@@ -0,0 +1,159 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import { promises as fs } from 'node:fs'
4
+ import { cosmiconfig } from 'cosmiconfig'
5
+ import { isUrl } from './helpers'
6
+
7
+ export const CONFIG_NAME = '.promptslotrc.json'
8
+ export const DEFAULT_PLACEHOLDER = '{{promptslot}}'
9
+ export const DEFAULT_MARKERS = {
10
+ start: '<!-- promptslot:start -->',
11
+ end: '<!-- promptslot:end -->',
12
+ }
13
+
14
+ export type ConfigScope = 'project' | 'global'
15
+
16
+ export type PromptslotConfig = {
17
+ placeholder: string
18
+ markers: {
19
+ start: string
20
+ end: string
21
+ }
22
+ prompts_dir?: string | null
23
+ prompts_root?: string | null
24
+ link_default?: string | null
25
+ current: string | null
26
+ sources: Record<string, string>
27
+ }
28
+
29
+ export type LoadedConfig = {
30
+ config: PromptslotConfig
31
+ configPath: string
32
+ baseDir: string
33
+ scope: ConfigScope
34
+ }
35
+
36
+ export type MergedConfig = {
37
+ config: PromptslotConfig
38
+ resolvedSources: Record<string, string>
39
+ hasConfig: boolean
40
+ }
41
+
42
+ const explorer = cosmiconfig('promptslot', {
43
+ searchPlaces: [CONFIG_NAME],
44
+ })
45
+
46
+ export const createDefaultConfig = (): PromptslotConfig => ({
47
+ placeholder: DEFAULT_PLACEHOLDER,
48
+ markers: { ...DEFAULT_MARKERS },
49
+ prompts_dir: null,
50
+ prompts_root: null,
51
+ link_default: 'AGENTS.md',
52
+ current: null,
53
+ sources: {},
54
+ })
55
+
56
+ export const getProjectConfigPath = () => path.join(process.cwd(), CONFIG_NAME)
57
+
58
+ export const getGlobalConfigPath = () => path.join(os.homedir(), CONFIG_NAME)
59
+
60
+ const resolveConfigPath = (scope: ConfigScope) => {
61
+ if (scope === 'global') return getGlobalConfigPath()
62
+ return getProjectConfigPath()
63
+ }
64
+
65
+ export const readConfigFile = async (scope: ConfigScope) => {
66
+ const configPath = resolveConfigPath(scope)
67
+ const result = await explorer.load(configPath).catch(() => null)
68
+ if (!result?.config) return null
69
+
70
+ return {
71
+ config: result.config as PromptslotConfig,
72
+ configPath,
73
+ baseDir: path.dirname(configPath),
74
+ scope,
75
+ }
76
+ }
77
+
78
+ export const writeConfigFile = async (scope: ConfigScope, config: PromptslotConfig) => {
79
+ const configPath = resolveConfigPath(scope)
80
+ const json = JSON.stringify(config, null, 2)
81
+ await fs.writeFile(configPath, `${json}\n`)
82
+ return configPath
83
+ }
84
+
85
+ export const resolvePromptsRoot = (config: PromptslotConfig, baseDir: string) => {
86
+ if (config.prompts_root) {
87
+ return path.resolve(baseDir, config.prompts_root)
88
+ }
89
+ if (config.prompts_dir) {
90
+ return path.resolve(baseDir, config.prompts_dir)
91
+ }
92
+ return null
93
+ }
94
+
95
+ export const resolveSourcePath = (
96
+ source: string,
97
+ config: PromptslotConfig,
98
+ baseDir: string,
99
+ ) => {
100
+ if (isUrl(source)) return source
101
+ if (path.isAbsolute(source)) return source
102
+ const promptsRoot = resolvePromptsRoot(config, baseDir)
103
+ if (promptsRoot) return path.resolve(promptsRoot, source)
104
+ return path.resolve(baseDir, source)
105
+ }
106
+
107
+ export const ensurePromptsRoot = async (config: PromptslotConfig, baseDir: string) => {
108
+ const promptsRoot = resolvePromptsRoot(config, baseDir)
109
+ if (!promptsRoot) return null
110
+ await fs.mkdir(promptsRoot, { recursive: true })
111
+ return promptsRoot
112
+ }
113
+
114
+ const mergeConfigs = (
115
+ globalConfig?: PromptslotConfig | null,
116
+ projectConfig?: PromptslotConfig | null,
117
+ ) => {
118
+ const base = createDefaultConfig()
119
+ return {
120
+ ...base,
121
+ ...(globalConfig ?? {}),
122
+ ...(projectConfig ?? {}),
123
+ markers: {
124
+ ...base.markers,
125
+ ...(globalConfig?.markers ?? {}),
126
+ ...(projectConfig?.markers ?? {}),
127
+ },
128
+ sources: {
129
+ ...(globalConfig?.sources ?? {}),
130
+ ...(projectConfig?.sources ?? {}),
131
+ },
132
+ }
133
+ }
134
+
135
+ const resolveSources = (loaded: LoadedConfig | null) => {
136
+ if (!loaded) return {}
137
+ return Object.fromEntries(
138
+ Object.entries(loaded.config.sources).map(([name, source]) => [
139
+ name,
140
+ resolveSourcePath(source, loaded.config, loaded.baseDir),
141
+ ]),
142
+ )
143
+ }
144
+
145
+ export const loadMergedConfig = async (): Promise<MergedConfig> => {
146
+ const globalConfig = await readConfigFile('global')
147
+ const projectConfig = await readConfigFile('project')
148
+ const config = mergeConfigs(globalConfig?.config, projectConfig?.config)
149
+ const resolvedSources = {
150
+ ...resolveSources(globalConfig),
151
+ ...resolveSources(projectConfig),
152
+ }
153
+
154
+ return {
155
+ config,
156
+ resolvedSources,
157
+ hasConfig: Boolean(globalConfig || projectConfig),
158
+ }
159
+ }
@@ -0,0 +1,14 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { isUrl } from './helpers'
3
+
4
+ export const readSourceText = async (sourcePath: string) => {
5
+ if (isUrl(sourcePath)) {
6
+ const response = await fetch(sourcePath)
7
+ if (!response.ok) {
8
+ throw new Error(`无法读取远程文件: ${response.status}`)
9
+ }
10
+ return response.text()
11
+ }
12
+
13
+ return fs.readFile(sourcePath, 'utf8')
14
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,95 @@
1
+ /// <reference types="bun" />
2
+ import path from 'node:path'
3
+ import { promises as fs } from 'node:fs'
4
+
5
+ export const resolvePath = (input: string, baseDir = process.cwd()) =>
6
+ path.resolve(baseDir, input)
7
+
8
+ export const fileExists = async (filePath: string) =>
9
+ fs.access(filePath).then(() => true).catch(() => false)
10
+
11
+ export const isSubpath = (parent: string, child: string) => {
12
+ const relative = path.relative(parent, child)
13
+ return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
14
+ }
15
+
16
+ export const listFiles = async (dirPath: string) => {
17
+ const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => [])
18
+ return entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
19
+ }
20
+
21
+ export const isFile = async (filePath: string) => {
22
+ const stats = await fs.stat(filePath).catch(() => null)
23
+ if (!stats) return false
24
+ return stats.isFile()
25
+ }
26
+
27
+ export const isUrl = (value: string) => /^https?:\/\//i.test(value)
28
+
29
+ export const getSourceExtension = (sourcePath: string) => {
30
+ if (isUrl(sourcePath)) {
31
+ const url = new URL(sourcePath)
32
+ return path.extname(url.pathname)
33
+ }
34
+ return path.extname(sourcePath)
35
+ }
36
+
37
+ export const ensureTargetExtension = (
38
+ targetPath: string,
39
+ sourcePath: string,
40
+ fallbackExtension = '.md',
41
+ ) => {
42
+ const sourceExtension = getSourceExtension(sourcePath) || fallbackExtension
43
+ const targetExtension = path.extname(targetPath)
44
+
45
+ if (!sourceExtension) return targetPath
46
+ if (!targetExtension) return `${targetPath}${sourceExtension}`
47
+ if (targetExtension === sourceExtension) return targetPath
48
+
49
+ return `${targetPath.slice(0, -targetExtension.length)}${sourceExtension}`
50
+ }
51
+
52
+ export const parseContentDisposition = (value: string | null) => {
53
+ if (!value) return null
54
+
55
+ const filenameMatch = /filename\*?=(?:UTF-8''|"?)([^";]+)/i.exec(value)
56
+ if (!filenameMatch?.[1]) return null
57
+
58
+ const filename = filenameMatch[1].trim()
59
+ if (!filename) return null
60
+
61
+ return decodeURIComponent(filename)
62
+ }
63
+
64
+ export const getUrlBasename = (value: string) => {
65
+ try {
66
+ const url = new URL(value)
67
+ if (url.pathname.endsWith('/')) return null
68
+
69
+ const base = path.basename(url.pathname)
70
+ if (!base || base === '/') return null
71
+ return base
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ export const resolveTargets = async (
78
+ inputs: string[],
79
+ excludedFiles: string[],
80
+ ) => {
81
+ if (inputs.length) return inputs
82
+
83
+ const glob = new Bun.Glob('**/*.md')
84
+ const targets: string[] = []
85
+ const excluded = new Set(excludedFiles.map((file) => path.resolve(file)))
86
+
87
+ for await (const file of glob.scan({ cwd: process.cwd(), onlyFiles: true })) {
88
+ if (file.includes('node_modules')) continue
89
+ const resolved = path.resolve(file)
90
+ if (excluded.has(resolved)) continue
91
+ targets.push(file)
92
+ }
93
+
94
+ return targets
95
+ }
@@ -0,0 +1,101 @@
1
+ import path from 'node:path'
2
+ import { promises as fs } from 'node:fs'
3
+
4
+ export type LinkIndex = {
5
+ sources: Record<string, string[]>
6
+ roots: string[]
7
+ }
8
+
9
+ const INDEX_FILE = '.promptslot-index.json'
10
+
11
+ const resolveIndexPath = () => path.join(process.cwd(), INDEX_FILE)
12
+
13
+ const normalizeTarget = (targetPath: string) =>
14
+ path.relative(process.cwd(), path.resolve(targetPath))
15
+
16
+ const normalizeRoot = (rootPath: string) => path.resolve(process.cwd(), rootPath)
17
+
18
+ export const readIndex = async (): Promise<LinkIndex> => {
19
+ const indexPath = resolveIndexPath()
20
+ const content = await fs.readFile(indexPath, 'utf8').catch(() => null)
21
+ if (!content) return { sources: {}, roots: [] }
22
+
23
+ try {
24
+ const parsed = JSON.parse(content) as LinkIndex
25
+ return {
26
+ sources: parsed.sources ?? {},
27
+ roots: parsed.roots ?? [],
28
+ }
29
+ } catch {
30
+ return { sources: {}, roots: [] }
31
+ }
32
+ }
33
+
34
+ export const writeIndex = async (index: LinkIndex) => {
35
+ const indexPath = resolveIndexPath()
36
+ const json = JSON.stringify(index, null, 2)
37
+ await fs.writeFile(indexPath, `${json}\n`)
38
+ return indexPath
39
+ }
40
+
41
+ export const addRoot = async (rootPath: string) => {
42
+ const index = await readIndex()
43
+ const normalized = normalizeRoot(rootPath)
44
+ if (!index.roots.includes(normalized)) {
45
+ index.roots.push(normalized)
46
+ await writeIndex(index)
47
+ }
48
+ }
49
+
50
+ export const recordLink = async (source: string, targetPath: string, rootPath: string) => {
51
+ const index = await readIndex()
52
+ const normalizedTarget = normalizeTarget(targetPath)
53
+ const entries = index.sources[source] ?? []
54
+
55
+ if (!entries.includes(normalizedTarget)) {
56
+ entries.push(normalizedTarget)
57
+ }
58
+
59
+ index.sources[source] = entries
60
+
61
+ const normalizedRoot = normalizeRoot(rootPath)
62
+ if (!index.roots.includes(normalizedRoot)) {
63
+ index.roots.push(normalizedRoot)
64
+ }
65
+
66
+ await writeIndex(index)
67
+ }
68
+
69
+ export const replaceLinkTarget = async (
70
+ source: string,
71
+ oldTargetPath: string,
72
+ newTargetPath: string,
73
+ ) => {
74
+ const index = await readIndex()
75
+ const normalizedOld = normalizeTarget(oldTargetPath)
76
+ const normalizedNew = normalizeTarget(newTargetPath)
77
+ const entries = index.sources[source] ?? []
78
+
79
+ const next = entries.filter((item) => item !== normalizedOld)
80
+ if (!next.includes(normalizedNew)) {
81
+ next.push(normalizedNew)
82
+ }
83
+
84
+ index.sources[source] = next
85
+ await writeIndex(index)
86
+ }
87
+
88
+ export const removeLinkTarget = async (source: string, targetPath: string) => {
89
+ const index = await readIndex()
90
+ const normalized = normalizeTarget(targetPath)
91
+ const entries = index.sources[source] ?? []
92
+ const next = entries.filter((item) => item !== normalized)
93
+
94
+ if (!next.length) {
95
+ delete index.sources[source]
96
+ } else {
97
+ index.sources[source] = next
98
+ }
99
+
100
+ await writeIndex(index)
101
+ }
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { readLinkMeta, stripLinkHeader, writeLinkFile } from './link'
5
+ import { waitForUnlock, withLock } from './lock'
6
+ import type { LinkMode } from './links-store'
7
+ import { isFile, isUrl } from './helpers'
8
+
9
+ const getLockPath = (sourcePath: string) => `${sourcePath}.lock`
10
+
11
+ const isRemote = (sourcePath: string) => isUrl(sourcePath)
12
+
13
+ const hashContent = (content: string) =>
14
+ crypto.createHash('sha256').update(content).digest('hex')
15
+
16
+ const readSource = async (sourcePath: string) => {
17
+ const content = await fs.readFile(sourcePath, 'utf8')
18
+ return { content, hash: hashContent(content) }
19
+ }
20
+
21
+ const readRemote = async (sourcePath: string) => {
22
+ const response = await fetch(sourcePath)
23
+ if (!response.ok) {
24
+ throw new Error(`无法读取远程文件: ${response.status}`)
25
+ }
26
+ const content = await response.text()
27
+ return { content, hash: hashContent(content) }
28
+ }
29
+
30
+ const readSourceContent = async (sourcePath: string) => {
31
+ if (isRemote(sourcePath)) {
32
+ return readRemote(sourcePath)
33
+ }
34
+ return readSource(sourcePath)
35
+ }
36
+
37
+ export const createLink = async (sourcePath: string, targetPath: string) => {
38
+ const source = await readSourceContent(sourcePath)
39
+ await writeLinkFile(targetPath, sourcePath, source.content, source.hash)
40
+ }
41
+
42
+ export const createAutoLink = async (sourcePath: string, targetPath: string) => {
43
+ if (!isRemote(sourcePath)) {
44
+ try {
45
+ await fs.link(sourcePath, targetPath)
46
+ return { mode: 'hardlink' as LinkMode }
47
+ } catch (error) {
48
+ const err = error as NodeJS.ErrnoException
49
+ const fallback = err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EACCES'
50
+ if (!fallback) throw error
51
+ }
52
+
53
+ try {
54
+ await fs.symlink(sourcePath, targetPath, 'file')
55
+ return { mode: 'symlink' as LinkMode }
56
+ } catch (error) {
57
+ const err = error as NodeJS.ErrnoException
58
+ const fallback = err.code === 'EPERM' || err.code === 'EACCES'
59
+ if (!fallback) throw error
60
+ }
61
+ }
62
+
63
+ await createLink(sourcePath, targetPath)
64
+ return { mode: 'meta' as LinkMode }
65
+ }
66
+
67
+ export const pullLink = async (sourcePath: string, targetPath: string) => {
68
+ if (!isRemote(sourcePath)) {
69
+ const lockPath = getLockPath(sourcePath)
70
+ await waitForUnlock(lockPath)
71
+ }
72
+
73
+ const source = await readSourceContent(sourcePath)
74
+ await writeLinkFile(targetPath, sourcePath, source.content, source.hash)
75
+ }
76
+
77
+ const resolveConflict = (sourceHash: string, linkHash: string | null, payloadHash: string) => {
78
+ if (!linkHash) return null
79
+ if (sourceHash !== linkHash && payloadHash !== linkHash) {
80
+ return '检测到冲突,请先 pull 更新后再写入'
81
+ }
82
+ return null
83
+ }
84
+
85
+ const resolveScopeChange = (sourceHash: string, linkHash: string | null, payloadHash: string) => {
86
+ if (!linkHash) return null
87
+ if (sourceHash !== linkHash && payloadHash === linkHash) {
88
+ return '源文件已更新,请先 pull 同步'
89
+ }
90
+ return null
91
+ }
92
+
93
+ export const pushLink = async (sourcePath: string, targetPath: string) => {
94
+ if (isRemote(sourcePath)) {
95
+ throw new Error('远程文件不支持写入')
96
+ }
97
+
98
+ const lockPath = getLockPath(sourcePath)
99
+ const link = await readLinkMeta(targetPath)
100
+ if (!link) {
101
+ throw new Error('文件不是 link 文件')
102
+ }
103
+
104
+ const payload = stripLinkHeader(link.content)
105
+ const payloadHash = hashContent(payload)
106
+
107
+ await withLock(lockPath, async () => {
108
+ const source = await readSourceContent(sourcePath)
109
+ const conflict = resolveConflict(source.hash, link.meta.hash, payloadHash)
110
+ if (conflict) {
111
+ throw new Error(conflict)
112
+ }
113
+
114
+ const sourceChanged = resolveScopeChange(source.hash, link.meta.hash, payloadHash)
115
+ if (sourceChanged) {
116
+ throw new Error(sourceChanged)
117
+ }
118
+
119
+ if (payloadHash !== source.hash) {
120
+ await fs.writeFile(sourcePath, payload)
121
+ }
122
+ })
123
+
124
+ const finalContent = await readSourceContent(sourcePath)
125
+ await writeLinkFile(targetPath, sourcePath, finalContent.content, finalContent.hash)
126
+
127
+ }
128
+
129
+ export const syncLink = async (sourcePath: string, targetPath: string) => {
130
+ if (isRemote(sourcePath)) {
131
+ throw new Error('远程文件不支持写入')
132
+ }
133
+
134
+ const lockPath = getLockPath(sourcePath)
135
+ const link = await readLinkMeta(targetPath)
136
+ if (!link) {
137
+ throw new Error('文件不是 link 文件')
138
+ }
139
+
140
+ const payload = stripLinkHeader(link.content)
141
+ const payloadHash = hashContent(payload)
142
+
143
+ await withLock(lockPath, async () => {
144
+ const source = await readSourceContent(sourcePath)
145
+
146
+ const conflict = resolveConflict(source.hash, link.meta.hash, payloadHash)
147
+ if (conflict) {
148
+ throw new Error(conflict)
149
+ }
150
+
151
+ const sourceChanged = resolveScopeChange(source.hash, link.meta.hash, payloadHash)
152
+ if (sourceChanged) {
153
+ throw new Error(sourceChanged)
154
+ }
155
+
156
+ if (payloadHash !== link.meta.hash && payloadHash !== source.hash) {
157
+ await fs.writeFile(sourcePath, payload)
158
+ }
159
+ })
160
+
161
+ const finalContent = await readSourceContent(sourcePath)
162
+ await writeLinkFile(targetPath, sourcePath, finalContent.content, finalContent.hash)
163
+ }
164
+
165
+ export const resolvePromptPath = (root: string, name: string) => {
166
+ const direct = path.resolve(root, name)
167
+ return direct
168
+ }
169
+
170
+ export const resolvePromptFile = async (root: string, name: string) => {
171
+ const direct = resolvePromptPath(root, name)
172
+ const directExists = await isFile(direct)
173
+ if (directExists) return direct
174
+
175
+ const withMd = resolvePromptPath(root, `${name}.md`)
176
+ const withMdExists = await isFile(withMd)
177
+ if (withMdExists) return withMd
178
+
179
+ return null
180
+ }
@@ -0,0 +1,30 @@
1
+ /// <reference types="bun" />
2
+ import path from 'node:path'
3
+ import { readLinks, writeLinks } from './links-store'
4
+ import { removeLinkTarget } from './link-index'
5
+ import { fileExists } from './helpers'
6
+
7
+ export const autoRepairLinks = async () => {
8
+ const entries = await readLinks()
9
+ const keys = Object.keys(entries)
10
+ if (!keys.length) return
11
+
12
+ let changed = false
13
+
14
+ for (const key of keys) {
15
+ const entry = entries[key]
16
+ if (entry.mode !== 'meta') continue
17
+
18
+ const targetPath = path.resolve(process.cwd(), key)
19
+ const exists = await fileExists(targetPath)
20
+ if (exists) continue
21
+
22
+ delete entries[key]
23
+ await removeLinkTarget(entry.source, targetPath)
24
+ changed = true
25
+ }
26
+
27
+ if (changed) {
28
+ await writeLinks(entries)
29
+ }
30
+ }