@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,244 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import {
4
+ createDefaultConfig,
5
+ ensurePromptsRoot,
6
+ getGlobalConfigPath,
7
+ getProjectConfigPath,
8
+ loadMergedConfig,
9
+ readConfigFile,
10
+ resolvePromptsRoot,
11
+ writeConfigFile,
12
+ type ConfigScope,
13
+ } from './config'
14
+ import { formatCommand } from './cli-name'
15
+ import { ask } from './prompt'
16
+ import { fileExists, isFile, isSubpath, isUrl, listFiles, resolvePath } from './helpers'
17
+
18
+
19
+ const log = (text: string) => console.log(text)
20
+ const fail = (text: string) => {
21
+ console.error(text)
22
+ process.exitCode = 1
23
+ }
24
+
25
+ const requireConfig = async (scope: ConfigScope) => {
26
+ const config = await readConfigFile(scope)
27
+ if (!config) {
28
+ const configPath = getConfigPathByScope(scope)
29
+ fail(`未找到配置文件: ${configPath}`)
30
+ return null
31
+ }
32
+ return config
33
+ }
34
+
35
+ const getConfigPathByScope = (scope: ConfigScope) => {
36
+ if (scope === 'global') return getGlobalConfigPath()
37
+ return getProjectConfigPath()
38
+ }
39
+
40
+ const getBaseDir = (scope: ConfigScope) => {
41
+ if (scope === 'global') return os.homedir()
42
+ return process.cwd()
43
+ }
44
+
45
+ const resolvePromptsPath = (base: string | null, source: string) => {
46
+ if (!base) return null
47
+ return resolvePath(source, base)
48
+ }
49
+
50
+ const resolveExisting = (
51
+ primary: string,
52
+ primaryExists: boolean,
53
+ fallback: string | null,
54
+ fallbackExists: boolean,
55
+ ) => {
56
+ if (primaryExists) return primary
57
+ if (fallbackExists && fallback) return fallback
58
+ return null
59
+ }
60
+
61
+ const listPromptSuggestions = async (root: string | null) => {
62
+ if (!root) return []
63
+ const files = await listFiles(root)
64
+ return files.sort()
65
+ }
66
+
67
+ const ensurePromptRepo = async (promptsRoot: string) => {
68
+ const gitDir = resolvePath('.git', promptsRoot)
69
+ const exists = await fileExists(gitDir)
70
+ if (exists) return
71
+
72
+ const { spawnSync } = await import('node:child_process')
73
+ const result = spawnSync('git', ['init'], { cwd: promptsRoot, encoding: 'utf8' })
74
+ if (result.status !== 0) {
75
+ const message = result.stderr || result.stdout || 'git init 失败'
76
+ log(`初始化 git 失败: ${message.trim()}`)
77
+ }
78
+ }
79
+
80
+ export const initConfig = async (scope: ConfigScope) => {
81
+ const configPath = getConfigPathByScope(scope)
82
+ const exists = await fileExists(configPath)
83
+ if (exists) {
84
+ log(`配置已存在: ${configPath}`)
85
+ return
86
+ }
87
+
88
+ const config = createDefaultConfig()
89
+ if (scope === 'global') {
90
+ const rootDefault = '.promptslot/prompts'
91
+ const promptRoot = await ask('全局 prompts 目录', rootDefault)
92
+ config.prompts_root = promptRoot
93
+ } else {
94
+ const dirDefault = 'prompts'
95
+ const promptDir = await ask('项目 prompts 目录', dirDefault)
96
+ config.prompts_dir = promptDir
97
+ }
98
+
99
+ const linkDefault = await ask('默认 link 文件名', config.link_default ?? 'AGENTS.md')
100
+ config.link_default = linkDefault
101
+
102
+
103
+ if (scope === 'project') {
104
+ const defaultSpec = resolvePath('规范.md')
105
+ const hasSpec = await fileExists(defaultSpec)
106
+ if (hasSpec) {
107
+ config.sources.spec = '规范.md'
108
+ config.current = 'spec'
109
+ }
110
+ }
111
+
112
+ await writeConfigFile(scope, config)
113
+ const promptsRoot = await ensurePromptsRoot(config, getBaseDir(scope))
114
+
115
+ if (scope === 'global' && promptsRoot) {
116
+ await ensurePromptRepo(promptsRoot)
117
+ }
118
+
119
+ log(`已生成配置: ${configPath}`)
120
+ }
121
+
122
+ export const setSource = async (name: string, source: string, scope: ConfigScope) => {
123
+ if (!name || !source) {
124
+ fail(`用法: ${formatCommand('set <name> <file>')}`)
125
+ return
126
+ }
127
+
128
+ const loaded = await requireConfig(scope)
129
+ if (!loaded) return
130
+
131
+ if (isUrl(source)) {
132
+ loaded.config.sources[name] = source
133
+ loaded.config.current = name
134
+ await writeConfigFile(scope, loaded.config)
135
+ log(`已设置 ${name} -> ${source}`)
136
+ return
137
+ }
138
+
139
+ const baseDir = getBaseDir(scope)
140
+ const promptsRoot = resolvePromptsRoot(loaded.config, loaded.baseDir)
141
+ const baseResolved = resolvePath(source, baseDir)
142
+ const promptsResolved = resolvePromptsPath(promptsRoot, source)
143
+ const baseExists = await fileExists(baseResolved)
144
+ let promptsExists = false
145
+ if (promptsResolved) {
146
+ promptsExists = await fileExists(promptsResolved)
147
+ }
148
+ const resolved = resolveExisting(
149
+ baseResolved,
150
+ baseExists,
151
+ promptsResolved,
152
+ promptsExists,
153
+ )
154
+
155
+ if (!resolved) {
156
+ const suggestions = await listPromptSuggestions(promptsRoot)
157
+ if (suggestions.length) {
158
+ log(`可选文件: ${suggestions.join(', ')}`)
159
+ }
160
+ fail(`文件不存在: ${baseResolved}`)
161
+ return
162
+ }
163
+
164
+ const isFilePath = await isFile(resolved)
165
+ if (!isFilePath) {
166
+ fail(`目标不是文件: ${resolved}`)
167
+ return
168
+ }
169
+ let relativeToPrompts: string | null = null
170
+ if (promptsRoot) {
171
+ relativeToPrompts = path.relative(promptsRoot, resolved)
172
+ }
173
+ const relativeToBase = path.relative(baseDir, resolved)
174
+ let stored = relativeToBase
175
+ if (promptsRoot && relativeToPrompts && isSubpath(promptsRoot, resolved)) {
176
+ stored = relativeToPrompts
177
+ }
178
+
179
+ loaded.config.sources[name] = stored
180
+ loaded.config.current = name
181
+ await writeConfigFile(scope, loaded.config)
182
+ log(`已设置 ${name} -> ${stored}`)
183
+ }
184
+
185
+ export const useSource = async (name: string, scope: ConfigScope) => {
186
+ if (!name) {
187
+ fail(`用法: ${formatCommand('use <name>')}`)
188
+ return
189
+ }
190
+
191
+ if (scope === 'global') {
192
+ const loaded = await requireConfig(scope)
193
+ if (!loaded) return
194
+
195
+ if (!loaded.config.sources[name]) {
196
+ fail(`未找到命名值: ${name}`)
197
+ return
198
+ }
199
+
200
+ loaded.config.current = name
201
+ await writeConfigFile(scope, loaded.config)
202
+ log(`已切换到 ${name}`)
203
+ return
204
+ }
205
+
206
+ const merged = await loadMergedConfig()
207
+ if (!merged.hasConfig) {
208
+ fail(`未找到配置文件,请先运行: ${formatCommand('init')}`)
209
+ return
210
+ }
211
+
212
+ if (!merged.resolvedSources[name]) {
213
+ fail(`未找到命名值: ${name}`)
214
+ return
215
+ }
216
+
217
+ const project = await requireConfig(scope)
218
+ if (!project) return
219
+ project.config.current = name
220
+ await writeConfigFile(scope, project.config)
221
+ log(`已切换到 ${name}`)
222
+ }
223
+
224
+ export const listSources = async () => {
225
+ const merged = await loadMergedConfig()
226
+ if (!merged.hasConfig) {
227
+ fail(`未找到配置文件,请先运行: ${formatCommand('init')}`)
228
+ return
229
+ }
230
+
231
+ const entries = Object.entries(merged.resolvedSources)
232
+ if (!entries.length) {
233
+ log('未配置任何 source')
234
+ return
235
+ }
236
+
237
+ entries.forEach(([name, source]) => {
238
+ let tag = ''
239
+ if (merged.config.current === name) {
240
+ tag = ' (current)'
241
+ }
242
+ log(`${name}: ${source}${tag}`)
243
+ })
244
+ }
@@ -0,0 +1,126 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { formatCommand } from './cli-name'
4
+ import type { ConfigScope } from './config'
5
+ import { ensureTargetExtension, fileExists, getUrlBasename, isUrl, listFiles, parseContentDisposition } from './helpers'
6
+ import { createAutoLink, resolvePromptFile } from './link-manager'
7
+ import { loadPromptRoot } from './prompt-root'
8
+ import { upsertLink } from './links-store'
9
+ import { recordLink } from './link-index'
10
+ import { ensureRenameDaemon } from './rename-daemon'
11
+
12
+ const log = (text: string) => console.log(text)
13
+ const fail = (text: string) => {
14
+ console.error(text)
15
+ process.exitCode = 1
16
+ }
17
+
18
+ const resolveTargetPath = (targetInput: string | undefined, fallback: string) => {
19
+ let target = fallback
20
+ if (targetInput) {
21
+ const trimmed = targetInput.trim()
22
+ if (trimmed) {
23
+ target = trimmed
24
+ }
25
+ }
26
+ return path.resolve(process.cwd(), target)
27
+ }
28
+
29
+ const listPromptNames = async (root: string) => {
30
+ const files = await listFiles(root)
31
+ return files.sort()
32
+ }
33
+
34
+ const resolveRoot = async (scope?: ConfigScope) => {
35
+ const info = await loadPromptRoot(scope)
36
+ if (info) return info.root
37
+ return null
38
+ }
39
+
40
+ const resolveRemoteFilename = async (url: string) => {
41
+ const response = await fetch(url, { method: 'HEAD' }).catch(() => null)
42
+ if (!response) return null
43
+ if (!response.ok) return null
44
+
45
+ const header = response.headers.get('content-disposition')
46
+ return parseContentDisposition(header)
47
+ }
48
+
49
+ const resolveDefaultTargetName = async (sourcePath: string) => {
50
+ if (isUrl(sourcePath)) {
51
+ const remoteName = await resolveRemoteFilename(sourcePath)
52
+ if (remoteName) return remoteName
53
+ return getUrlBasename(sourcePath)
54
+ }
55
+
56
+ return path.basename(sourcePath)
57
+ }
58
+
59
+ const resolveLinkSource = async (root: string, name: string) => {
60
+ if (isUrl(name)) return name
61
+
62
+ const source = await resolvePromptFile(root, name)
63
+ if (source) return source
64
+
65
+ const suggestions = await listPromptNames(root)
66
+ if (suggestions.length) {
67
+ log(`可选文件: ${suggestions.join(', ')}`)
68
+ }
69
+ return null
70
+ }
71
+
72
+ export const linkFile = async (name: string, target?: string, scope?: ConfigScope) => {
73
+ if (!name) {
74
+ fail(`用法: ${formatCommand('link <name> [file] [scope]')}`)
75
+ return
76
+ }
77
+
78
+ const root = await resolveRoot(scope)
79
+ if (!root && !isUrl(name)) {
80
+ fail('未配置 prompts_root,请先运行 init 配置全局目录')
81
+ return
82
+ }
83
+
84
+ const sourcePath = await resolveLinkSource(root ?? '', name)
85
+ if (!sourcePath) {
86
+ fail(`未找到 prompt 文件: ${name}`)
87
+ return
88
+ }
89
+
90
+ let baseTarget = ''
91
+ if (target) {
92
+ baseTarget = resolveTargetPath(target, target)
93
+ } else {
94
+ const defaultName = await resolveDefaultTargetName(sourcePath)
95
+ if (!defaultName) {
96
+ fail('无法确定目标文件名,请显式传入目标路径')
97
+ return
98
+ }
99
+ baseTarget = resolveTargetPath(defaultName, defaultName)
100
+ }
101
+
102
+ const targetPath = ensureTargetExtension(baseTarget, sourcePath)
103
+ const exists = await fileExists(targetPath)
104
+ if (exists) {
105
+ fail(`目标文件已存在: ${targetPath}`)
106
+ return
107
+ }
108
+
109
+ await fs.mkdir(path.dirname(targetPath), { recursive: true })
110
+ const { mode } = await createAutoLink(sourcePath, targetPath)
111
+ await upsertLink(targetPath, { source: sourcePath, mode })
112
+ await recordLink(sourcePath, targetPath, process.cwd())
113
+
114
+ if (mode === 'hardlink') {
115
+ log(`已创建硬链接: ${targetPath}`)
116
+ return
117
+ }
118
+
119
+ if (mode === 'symlink') {
120
+ log(`已创建软链接: ${targetPath}`)
121
+ return
122
+ }
123
+
124
+ log(`已创建 link 文件: ${targetPath}`)
125
+ void ensureRenameDaemon()
126
+ }
@@ -0,0 +1,47 @@
1
+ import path from 'node:path'
2
+ import { readConfigFile, type ConfigScope } from './config'
3
+ import { formatCommand } from './cli-name'
4
+
5
+ const fail = (text: string) => {
6
+ console.error(text)
7
+ process.exitCode = 1
8
+ }
9
+
10
+ const openFolder = async (folder: string) => {
11
+ const { spawn } = await import('node:child_process')
12
+ let command = 'xdg-open'
13
+ const args = [folder]
14
+
15
+ if (process.platform === 'win32') {
16
+ command = 'explorer'
17
+ } else if (process.platform === 'darwin') {
18
+ command = 'open'
19
+ }
20
+
21
+ const proc = spawn(command, args, {
22
+ detached: true,
23
+ stdio: 'ignore',
24
+ })
25
+ proc.unref()
26
+ }
27
+
28
+ const resolveConfigPath = async (scope?: ConfigScope) => {
29
+ if (scope === 'project') return readConfigFile('project')
30
+ if (scope === 'global') return readConfigFile('global')
31
+
32
+ const project = await readConfigFile('project')
33
+ if (project) return project
34
+
35
+ return readConfigFile('global')
36
+ }
37
+
38
+ export const openConfigFolder = async (scope?: ConfigScope) => {
39
+ const config = await resolveConfigPath(scope)
40
+ if (!config) {
41
+ fail(`未找到配置文件,请先运行: ${formatCommand('init')}`)
42
+ return
43
+ }
44
+
45
+ const folder = path.dirname(config.configPath)
46
+ await openFolder(folder)
47
+ }
@@ -0,0 +1,52 @@
1
+ import { linkFile } from './command-link'
2
+ import type { ConfigScope } from './config'
3
+ import { formatCommand } from './cli-name'
4
+ import { listFiles } from './helpers'
5
+ import { loadPromptRoot } from './prompt-root'
6
+
7
+ const log = (text: string) => console.log(text)
8
+ const fail = (text: string) => {
9
+ console.error(text)
10
+ process.exitCode = 1
11
+ }
12
+
13
+ const pickFromList = async (names: string[]) => {
14
+ if (!process.stdin.isTTY) return null
15
+ if (!names.length) return null
16
+
17
+ const module = await import('enquirer')
18
+ const AutoComplete = (module as unknown as { AutoComplete: new (...args: unknown[]) => { run: () => Promise<string> } }).AutoComplete
19
+
20
+ const prompt = new AutoComplete({
21
+ name: 'prompt',
22
+ message: '选择 prompt 文件',
23
+ choices: names,
24
+ limit: 10,
25
+ })
26
+
27
+ const selected = await prompt.run().catch(() => null)
28
+ if (typeof selected === 'string') return selected
29
+ return null
30
+ }
31
+
32
+ export const searchAndLink = async (target?: string, scope?: ConfigScope) => {
33
+ const info = await loadPromptRoot(scope)
34
+ if (!info) {
35
+ fail(`未配置 prompts_root,请先运行: ${formatCommand('init')}`)
36
+ return
37
+ }
38
+
39
+ const names = await listFiles(info.root)
40
+ if (!names.length) {
41
+ log('未找到任何 prompt 文件')
42
+ return
43
+ }
44
+
45
+ const selected = await pickFromList(names.sort())
46
+ if (!selected) {
47
+ log('未选择任何文件')
48
+ return
49
+ }
50
+
51
+ await linkFile(selected, target, scope)
52
+ }
@@ -0,0 +1,27 @@
1
+ import path from 'node:path'
2
+ import { readIndex } from './link-index'
3
+ import { readLinks } from './links-store'
4
+ import { fileExists } from './helpers'
5
+
6
+ const log = (text: string) => console.log(text)
7
+
8
+ export const statusLinks = async () => {
9
+ const links = await readLinks()
10
+ const entries = Object.entries(links)
11
+ if (!entries.length) {
12
+ log('未发现 link 记录')
13
+ return
14
+ }
15
+
16
+ for (const [target, entry] of entries) {
17
+ const targetPath = path.resolve(process.cwd(), target)
18
+ const exists = await fileExists(targetPath)
19
+ const flag = exists ? 'ok' : 'missing'
20
+ log(`${target} -> ${entry.source} [${entry.mode}] ${flag}`)
21
+ }
22
+
23
+ const index = await readIndex()
24
+ if (index.roots.length) {
25
+ log(`roots: ${index.roots.join(', ')}`)
26
+ }
27
+ }
@@ -0,0 +1,165 @@
1
+ import { Command } from 'commander'
2
+ import { applyCurrent, insertPlaceholder } from './command-apply'
3
+ import { initConfig, listSources, setSource, useSource } from './command-config'
4
+ import { linkFile } from './command-link'
5
+ import { searchAndLink } from './command-search'
6
+ import { openConfigFolder } from './command-open'
7
+ import { statusLinks } from './command-status'
8
+ import { runRenameDaemon } from './rename-daemon'
9
+ import { resolveScope } from './scope'
10
+ import type { ConfigScope } from './config'
11
+
12
+
13
+ export const registerCommands = (program: Command) => {
14
+ program
15
+ .command('init')
16
+ .description('初始化全局配置')
17
+ .action(async () => {
18
+ await initConfig('global')
19
+ })
20
+
21
+ program
22
+ .command('set')
23
+ .description('设置并切换值')
24
+ .argument('<name>', '命名 key,如 spec')
25
+ .argument('<file>', 'prompt 文件路径')
26
+ .argument('[scope]', 'project | global,默认 global')
27
+ .action(async (name, file, scopeInput) => {
28
+ const { scope, error } = resolveScope(scopeInput)
29
+ if (error) {
30
+ console.error(error)
31
+ process.exitCode = 1
32
+ return
33
+ }
34
+ await setSource(name, file, scope)
35
+ })
36
+
37
+ program
38
+ .command('use')
39
+ .description('切换到已保存的值')
40
+ .argument('<name>', '命名 key,如 spec')
41
+ .argument('[scope]', 'project | global,默认 global')
42
+ .action(async (name, scopeInput) => {
43
+ const { scope, error } = resolveScope(scopeInput)
44
+ if (error) {
45
+ console.error(error)
46
+ process.exitCode = 1
47
+ return
48
+ }
49
+ await useSource(name, scope)
50
+ })
51
+
52
+
53
+ program
54
+ .command('insert')
55
+ .description('插入占位符')
56
+ .argument('<file>', '目标文件路径')
57
+ .action(async (file) => {
58
+ await insertPlaceholder(file)
59
+ })
60
+
61
+ program
62
+ .command('apply')
63
+ .description('应用当前值')
64
+ .argument('[files...]', '指定要替换的文件列表')
65
+ .action(async (files: string[] = []) => {
66
+ await applyCurrent(files)
67
+ })
68
+
69
+ program
70
+ .command('link')
71
+ .description('创建 link 文件')
72
+ .argument('[name]', 'prompt 文件名(可省略进入交互)')
73
+ .argument('[file]', '目标文件路径')
74
+ .argument('[scope]', 'project | global,默认 global')
75
+ .option('-i', '交互搜索')
76
+ .action(async (name, file, scopeInput, options) => {
77
+ const interactive = options.i || !name || name === '-'
78
+ let targetFile = file
79
+ let scopeArg = scopeInput
80
+
81
+ if (interactive) {
82
+ if (name && name !== '-') {
83
+ targetFile = name
84
+ scopeArg = file
85
+ }
86
+ let scope: ConfigScope | undefined
87
+ if (scopeArg) {
88
+ const result = resolveScope(scopeArg)
89
+ if (result.error) {
90
+ console.error(result.error)
91
+ process.exitCode = 1
92
+ return
93
+ }
94
+ scope = result.scope
95
+ }
96
+ await searchAndLink(targetFile, scope)
97
+ return
98
+ }
99
+
100
+ let scope: ConfigScope | undefined
101
+ if (scopeInput) {
102
+ const result = resolveScope(scopeInput)
103
+ if (result.error) {
104
+ console.error(result.error)
105
+ process.exitCode = 1
106
+ return
107
+ }
108
+ scope = result.scope
109
+ }
110
+ await linkFile(name, file, scope)
111
+ })
112
+
113
+ program
114
+ .command('search')
115
+ .description('交互搜索并创建 link')
116
+ .argument('[file]', '目标文件路径')
117
+ .argument('[scope]', 'project | global,默认 global')
118
+ .action(async (file, scopeInput) => {
119
+ let scope: ConfigScope | undefined
120
+ if (scopeInput) {
121
+ const result = resolveScope(scopeInput)
122
+ if (result.error) {
123
+ console.error(result.error)
124
+ process.exitCode = 1
125
+ return
126
+ }
127
+ scope = result.scope
128
+ }
129
+ await searchAndLink(file, scope)
130
+ })
131
+
132
+ program
133
+ .command('open-config')
134
+ .description('打开配置所在文件夹')
135
+ .argument('[scope]', 'project | global,默认 global')
136
+ .action(async (scopeInput) => {
137
+ let scope: ConfigScope | undefined
138
+ if (scopeInput) {
139
+ const result = resolveScope(scopeInput)
140
+ if (result.error) {
141
+ console.error(result.error)
142
+ process.exitCode = 1
143
+ return
144
+ }
145
+ scope = result.scope
146
+ }
147
+ await openConfigFolder(scope)
148
+ })
149
+ .alias('open')
150
+
151
+ program
152
+ .command('status')
153
+ .description('查看 link 状态')
154
+ .action(async () => {
155
+ await statusLinks()
156
+ })
157
+
158
+ program
159
+ .command('rename-daemon', { hidden: true })
160
+ .description('内部重命名监听')
161
+ .action(async () => {
162
+ await runRenameDaemon()
163
+ })
164
+
165
+ }