dsh-plugin-git-commit-push 0.0.0-stage → 1.0.1
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.
- package/LICENSE +21 -0
- package/README.en.md +473 -0
- package/README.md +381 -2
- package/SKILL.md +79 -0
- package/capture-git-format.mjs +93 -0
- package/cordis.patch.yml +35 -0
- package/e2e-check.mjs +26 -0
- package/git-commit-push.config.json +17 -0
- package/icon.svg +1 -0
- package/index.js +908 -0
- package/lib/analyze.js +517 -0
- package/lib/config.js +300 -0
- package/lib/git.js +562 -0
- package/lib/profile-edit.mjs +118 -0
- package/lib/schema.js +111 -0
- package/lib/skill.js +95 -0
- package/lib/survey.js +225 -0
- package/lib/test-fixture.mjs +64 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
- package/self-test-git.mjs +442 -0
- package/self-test.mjs +913 -0
- package/setup.ps1 +226 -0
- package/setup.sh +190 -0
package/index.js
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-git-commit-push — a one-call Conventional-Commits workflow for DSH.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* The skill it replaces asks a model to run `git status`, read `.gitignore`,
|
|
6
|
+
* inspect a diff, call `git log` for tone, write a message, then commit, tag and
|
|
7
|
+
* push — ten-odd tool calls whose OUTPUT is mostly raw git text. A plugin does
|
|
8
|
+
* the reading in-process, where bytes are free, and hands back one compact card.
|
|
9
|
+
* The model pays for a summary instead of a diff, and pays for a question only
|
|
10
|
+
* when a tag is genuinely warranted (the in-plugin question costs no tokens at
|
|
11
|
+
* all, because it never becomes a model message).
|
|
12
|
+
*
|
|
13
|
+
* WHEN IT MAY BE USED — the trigger policy
|
|
14
|
+
* ONLY when the user asks for it: they typed the `/git-commit-push` slash command,
|
|
15
|
+
* or they said in words that they want a commit and/or push. There is no
|
|
16
|
+
* automatic path. Finishing an edit, completing a task, or approaching the end
|
|
17
|
+
* of a session is NOT a trigger, and the tool description states this to the
|
|
18
|
+
* model in the strongest terms available. The plugin cannot enforce the rule
|
|
19
|
+
* from inside `execute` — by then the decision has already been made — so the
|
|
20
|
+
* enforcement lives in the tool description (which the model reads when
|
|
21
|
+
* choosing a tool) and in the accompanying SKILL.md.
|
|
22
|
+
*
|
|
23
|
+
* WHAT IT WILL NOT DO
|
|
24
|
+
* Never modifies `.gitignore`, git config or `user.name`/`user.email`; never
|
|
25
|
+
* `push --force`, `reset --hard`, `clean`, or `checkout --` (see lib/git.js for
|
|
26
|
+
* the fixed command set); never commits in a container directory — it reports
|
|
27
|
+
* the candidate repositories instead.
|
|
28
|
+
*/
|
|
29
|
+
import {
|
|
30
|
+
loadSettingsReport, resolveSettings, uiOverrides,
|
|
31
|
+
CONFIG_PATH, userConfigPath, configCandidates,
|
|
32
|
+
} from './lib/config.js'
|
|
33
|
+
import { CONFIG } from './lib/schema.js'
|
|
34
|
+
import { skillDefinition } from './lib/skill.js'
|
|
35
|
+
import { survey } from './lib/survey.js'
|
|
36
|
+
import {
|
|
37
|
+
commit, createTag, headShort, numstat, push, pushAfterRebase,
|
|
38
|
+
stageAll, tagExists, tagNameError,
|
|
39
|
+
} from './lib/git.js'
|
|
40
|
+
import { renderCard, totalsOf } from './lib/analyze.js'
|
|
41
|
+
|
|
42
|
+
export const name = 'git-commit-push'
|
|
43
|
+
export const inject = ['tools']
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tool name the model sees. Underscored, not hyphenated: DSH tool names use
|
|
47
|
+
* snake_case (`read_image`, `web_fetch`), and the skill references this exact
|
|
48
|
+
* string.
|
|
49
|
+
*/
|
|
50
|
+
export const TOOL_NAME = 'git_commit_push'
|
|
51
|
+
|
|
52
|
+
/** Slash command name, without the leading slash. */
|
|
53
|
+
export const COMMAND_NAME = 'git-commit-push'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reduce any thrown value to one bounded, printable line.
|
|
57
|
+
*
|
|
58
|
+
* Git stderr can be long and multi-line; a commit failure should not push a
|
|
59
|
+
* screenful of remote narration into the transcript.
|
|
60
|
+
*/
|
|
61
|
+
function brief(error) {
|
|
62
|
+
const raw = error instanceof Error ? error.message : String(error)
|
|
63
|
+
const text = raw.replace(/\s+/g, ' ').trim()
|
|
64
|
+
return text.length > 400 ? `${text.slice(0, 400)}…` : text
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The session working directory, with the host process cwd as last resort. */
|
|
68
|
+
function cwdOf(exec) {
|
|
69
|
+
const header = exec?.agent?.session?.header?.cwd
|
|
70
|
+
if (typeof header === 'string' && header !== '') return header
|
|
71
|
+
return process.cwd()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether the settings ask for a tag question, and what name to suggest.
|
|
76
|
+
*
|
|
77
|
+
* @returns {{ shouldAsk: boolean, tag?: string, reasons: string[] }}
|
|
78
|
+
*/
|
|
79
|
+
function tagDecision(surveyResult, settings, override) {
|
|
80
|
+
const reasons = []
|
|
81
|
+
if (settings.tagOnVersionChange && surveyResult.version !== undefined) {
|
|
82
|
+
reasons.push(`版本号 ${surveyResult.version.from ?? '?'} → ${surveyResult.version.to ?? '?'}`)
|
|
83
|
+
}
|
|
84
|
+
if (settings.tagOnBreaking && surveyResult.breaking) reasons.push('可能的破坏性变更')
|
|
85
|
+
if (settings.tagOnFileCount > 0 && surveyResult.entries.length >= settings.tagOnFileCount) {
|
|
86
|
+
reasons.push(`改动文件 ${surveyResult.entries.length} 个`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let tag = override
|
|
90
|
+
if (tag === undefined && reasons.length > 0) {
|
|
91
|
+
const version = surveyResult.version?.to
|
|
92
|
+
if (typeof version === 'string' && version !== '') {
|
|
93
|
+
tag = version.startsWith(settings.tagPrefix) ? version : `${settings.tagPrefix}${version.replace(/^v/, '')}`
|
|
94
|
+
} else {
|
|
95
|
+
const now = new Date()
|
|
96
|
+
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
|
|
97
|
+
tag = `${settings.tagPrefix}${stamp}`
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { shouldAsk: reasons.length > 0, ...(tag === undefined ? {} : { tag }), reasons }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Ask the user to confirm a tag, from inside the tool call.
|
|
105
|
+
*
|
|
106
|
+
* This is the token-saving move the skill cannot make: the question is answered
|
|
107
|
+
* in the plugin's own UI card and never becomes a model message.
|
|
108
|
+
*
|
|
109
|
+
* Failure policy: when the question cannot be asked at all (a delegated caller
|
|
110
|
+
* has no human answerer, an older host lacks the service, or the wait times
|
|
111
|
+
* out) the plugin does NOT tag. Creating a release tag is an outward-facing,
|
|
112
|
+
* hard-to-undo act, so "nobody answered" must mean "do not", and the note tells
|
|
113
|
+
* the caller exactly how to force it. Only `askBeforeTag: false` tags silently,
|
|
114
|
+
* because that is the user explicitly opting in.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<{ decided: string | undefined, via: 'user' | 'auto' | 'skipped', note?: string }>}
|
|
117
|
+
*/
|
|
118
|
+
async function askTag(ctx, exec, tag, reasons, settings) {
|
|
119
|
+
if (tag === undefined) return { decided: undefined, via: 'skipped' }
|
|
120
|
+
if (settings.askBeforeTag !== true) return { decided: tag, via: 'auto' }
|
|
121
|
+
// A question needs a live caller to answer it. Without one (a direct `run`
|
|
122
|
+
// call with no tool context) there is nothing to ask, and the safe answer is
|
|
123
|
+
// the same as everywhere else in this function: do not tag.
|
|
124
|
+
if (exec?.agent === undefined) {
|
|
125
|
+
return { decided: undefined, via: 'skipped', note: `没有可询问的会话上下文,本次未打标签(可用 tag 参数指定 ${tag})` }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const service = ctx.get('userQuestions')
|
|
129
|
+
if (service === undefined) {
|
|
130
|
+
return { decided: undefined, via: 'skipped', note: `未安装提问服务,本次未打标签(可用 tag 参数指定 ${tag})` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const questions = [{
|
|
134
|
+
id: 'git-commit-push-tag',
|
|
135
|
+
header: 'Git 标签',
|
|
136
|
+
question: `本次改动满足打标签条件(${reasons.join(';')})。是否创建标签 ${tag}?`,
|
|
137
|
+
options: [
|
|
138
|
+
{ label: `添加 ${tag}`, description: `创建标签 ${tag} 并随分支一起推送` },
|
|
139
|
+
{ label: '不添加', description: '只提交并推送提交,不创建标签' },
|
|
140
|
+
],
|
|
141
|
+
}]
|
|
142
|
+
const request = { agent: exec.agent, signal: exec.signal, questions }
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const timed = typeof service.askTimed === 'function'
|
|
146
|
+
const answer = timed
|
|
147
|
+
? await service.askTimed(request, exec.callId, settings.askTimeoutMs)
|
|
148
|
+
: await service.ask(request)
|
|
149
|
+
|
|
150
|
+
if (timed && answer !== null && typeof answer === 'object' && 'pending' in answer) {
|
|
151
|
+
return { decided: undefined, via: 'skipped', note: `等待标签确认超时,本次未打标签(可用 tag 参数指定 ${tag})` }
|
|
152
|
+
}
|
|
153
|
+
const selected = answer?.answers?.find(item => item.id === 'git-commit-push-tag')?.selected?.[0]
|
|
154
|
+
if (typeof selected === 'string' && selected.startsWith('添加')) return { decided: tag, via: 'user' }
|
|
155
|
+
return { decided: undefined, via: 'user' }
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message = brief(error)
|
|
158
|
+
// Match the documented error CODES, not just their names: the service
|
|
159
|
+
// reports `DELEGATED_CALLER`/`CALLER_NOT_LIVE` in `error.code` as well as in
|
|
160
|
+
// the message, and a host that reworded the message must not change whether
|
|
161
|
+
// we tag.
|
|
162
|
+
const code = typeof error?.code === 'string' ? error.code : ''
|
|
163
|
+
const delegated = /DELEGATED_CALLER|CALLER_NOT_LIVE/.test(code) || /DELEGATED_CALLER|CALLER_NOT_LIVE/.test(message)
|
|
164
|
+
return {
|
|
165
|
+
decided: undefined,
|
|
166
|
+
via: 'skipped',
|
|
167
|
+
note: delegated
|
|
168
|
+
? `当前上下文无法询问用户,本次未打标签(可用 tag 参数指定 ${tag})`
|
|
169
|
+
: `标签确认失败(${message}),本次未打标签(可用 tag 参数指定 ${tag})`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stage, classify and commit.
|
|
176
|
+
*
|
|
177
|
+
* @returns {Promise<{ ok: boolean, hash?: string, subject?: string, error?: string, email?: string }>}
|
|
178
|
+
*/
|
|
179
|
+
async function doCommit(ctx, exec, options) {
|
|
180
|
+
const settings = options.settings
|
|
181
|
+
const root = options.root
|
|
182
|
+
const message = options.message
|
|
183
|
+
const entries = options.entries
|
|
184
|
+
|
|
185
|
+
if (settings.autoAdd) {
|
|
186
|
+
// `exec` is present for a tool call and absent for a `/git-commit-push` invocation
|
|
187
|
+
// driven by tests or an embedding host, so the optional chain is required.
|
|
188
|
+
//
|
|
189
|
+
// AWAITED on purpose: this used to race the staged-numstat read below, so a
|
|
190
|
+
// real commit reported `+0 / -0` on the card — a wrong number right where a
|
|
191
|
+
// person looks for confirmation.
|
|
192
|
+
await stageAll(root, exec?.signal)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stagedStats = await numstat(root, true)
|
|
196
|
+
// Guard the one setting that can make the commit itself impossible: with
|
|
197
|
+
// autoAdd off, an empty index fails with git's own "nothing added to commit",
|
|
198
|
+
// which reads like a plugin bug rather than a setting. When autoAdd is ON an
|
|
199
|
+
// empty index is legitimate — a repository whose first commit has no `HEAD`
|
|
200
|
+
// yet cannot produce a numstat at all — so the commit is attempted and git's
|
|
201
|
+
// own answer is reported if there is truly nothing to commit.
|
|
202
|
+
if (settings.autoAdd !== true && stagedStats.size === 0) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: '暂存区为空:autoAdd 已关闭且没有任何文件被暂存。请先 git add,或把配置里的 autoAdd 改回 true。',
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const { added, deleted } = totalsOf(stagedStats)
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await commit(root, message, settings.pinnedIdentity)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const text = brief(error)
|
|
214
|
+
// A hook rejection is reported, never bypassed: `--no-verify` is not used
|
|
215
|
+
// anywhere in this plugin.
|
|
216
|
+
return { ok: false, error: text }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const hash = await headShort(root)
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
...(hash === undefined ? {} : { hash }),
|
|
223
|
+
subject: message.split('\n')[0],
|
|
224
|
+
files: entries.length,
|
|
225
|
+
added,
|
|
226
|
+
deleted,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create the tag and push branch + tag.
|
|
232
|
+
*
|
|
233
|
+
* The branch push and the tag push are separate commands so a tag rejection
|
|
234
|
+
* (the common "tag already exists on the remote" case) leaves a successful
|
|
235
|
+
* branch push fully verified instead of reported as an ambiguous single failure.
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<{ pushed: boolean, tagCreated?: string, tagPushed?: boolean, note?: string, reason?: string }>}
|
|
238
|
+
*/
|
|
239
|
+
async function doTagAndPush(ctx, exec, options) {
|
|
240
|
+
const { settings, root, branch, tag, upstream } = options
|
|
241
|
+
const result = { pushed: false }
|
|
242
|
+
// Notes accumulate; a later fact must never silently overwrite an earlier one.
|
|
243
|
+
const notes = []
|
|
244
|
+
const note = text => { notes.push(text) }
|
|
245
|
+
|
|
246
|
+
if (tag !== undefined) {
|
|
247
|
+
const invalid = await tagNameError(root, tag)
|
|
248
|
+
if (invalid !== undefined) {
|
|
249
|
+
note(`标签名无效(${invalid}),已跳过打标签`)
|
|
250
|
+
} else if (await tagExists(root, tag)) {
|
|
251
|
+
note(`标签 ${tag} 已存在,已跳过`)
|
|
252
|
+
} else {
|
|
253
|
+
try {
|
|
254
|
+
await createTag(root, tag)
|
|
255
|
+
result.tagCreated = tag
|
|
256
|
+
} catch (error) {
|
|
257
|
+
note(`创建标签失败:${brief(error)}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (settings.autoPush !== true) {
|
|
263
|
+
note('autoPush 已关闭,未推送')
|
|
264
|
+
result.note = notes.join(';')
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const pushed = await pushAfterRebase(root, branch, { hasUpstream: upstream })
|
|
269
|
+
if (pushed.ok) {
|
|
270
|
+
result.pushed = true
|
|
271
|
+
if (pushed.rebased) note('远程有新提交,已 rebase 后重推')
|
|
272
|
+
if (result.tagCreated !== undefined) {
|
|
273
|
+
// The tag is a second command so its failure cannot be confused with the
|
|
274
|
+
// branch push. A rejected tag push does NOT undo a successful push and is
|
|
275
|
+
// reported as a note, not as a failed commit.
|
|
276
|
+
const tagPush = await push(root, branch, { hasUpstream: true, tag: result.tagCreated })
|
|
277
|
+
result.tagPushed = tagPush.ok
|
|
278
|
+
if (!tagPush.ok) note(`分支已推送,但标签 ${result.tagCreated} 推送失败(${tagPush.reason ?? '未知原因'})`)
|
|
279
|
+
}
|
|
280
|
+
result.note = notes.length > 0 ? notes.join(';') : undefined
|
|
281
|
+
return result
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
result.reason = pushed.reason
|
|
285
|
+
note(pushed.reason === 'rebase-conflict'
|
|
286
|
+
? '推送被拒且 rebase 出现冲突,已中止 rebase 并保留你的改动,需手动处理'
|
|
287
|
+
: `推送失败(${pushed.reason ?? '未知原因'})`)
|
|
288
|
+
result.note = notes.join(';')
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build the canonical value every action returns.
|
|
294
|
+
*
|
|
295
|
+
* `card` is part of the canonical value on purpose: `output.render` is a pure
|
|
296
|
+
* projection of `(args, value)`, so it cannot derive a card that needs the
|
|
297
|
+
* changeset and the push result unless the value carries it. Keeping it in the
|
|
298
|
+
* value also means a PTC caller gets the same text a human sees.
|
|
299
|
+
*/
|
|
300
|
+
function valueOf(fields) {
|
|
301
|
+
return {
|
|
302
|
+
ok: fields.ok === true,
|
|
303
|
+
// `action` is required by the output schema, and `run` is exported, so a
|
|
304
|
+
// direct caller that omits it must still produce a schema-valid value.
|
|
305
|
+
action: fields.action ?? 'auto',
|
|
306
|
+
card: fields.card ?? '',
|
|
307
|
+
...(fields.root === undefined ? {} : { root: fields.root }),
|
|
308
|
+
...(fields.branch === undefined ? {} : { branch: fields.branch }),
|
|
309
|
+
...(fields.files === undefined ? {} : { files: fields.files }),
|
|
310
|
+
...(fields.changes === undefined ? {} : { changes: fields.changes }),
|
|
311
|
+
...(fields.hash === undefined ? {} : { hash: fields.hash }),
|
|
312
|
+
...(fields.draft === undefined ? {} : { draft: fields.draft }),
|
|
313
|
+
...(fields.tag === undefined ? {} : { tag: fields.tag }),
|
|
314
|
+
...(fields.tagCreated === undefined ? {} : { tagCreated: fields.tagCreated }),
|
|
315
|
+
pushed: fields.pushed === true,
|
|
316
|
+
...(fields.note === undefined ? {} : { note: fields.note }),
|
|
317
|
+
...(fields.error === undefined ? {} : { error: fields.error }),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The compact markdown card for a survey.
|
|
323
|
+
*
|
|
324
|
+
* @param {object} surveyResult
|
|
325
|
+
* @param {{ maxFilesShown: number }} settings
|
|
326
|
+
*/
|
|
327
|
+
function prepareCard(surveyResult, settings) {
|
|
328
|
+
return renderCard({
|
|
329
|
+
branch: surveyResult.branch,
|
|
330
|
+
entries: surveyResult.entries,
|
|
331
|
+
stats: surveyResult.stats,
|
|
332
|
+
recentSubjects: surveyResult.recentSubjects,
|
|
333
|
+
version: surveyResult.version,
|
|
334
|
+
breaking: surveyResult.breaking,
|
|
335
|
+
draft: surveyResult.draft,
|
|
336
|
+
maxFiles: settings.maxFilesShown,
|
|
337
|
+
hasUpstream: surveyResult.hasUpstream,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The compact markdown card for a completed commit.
|
|
343
|
+
*
|
|
344
|
+
* The first line is the VERDICT, not a label. A person who never expands the
|
|
345
|
+
* card still learns whether the work landed and whether it was pushed — the
|
|
346
|
+
* earlier `**git commit** · …` header was easy to mistake for a log line and
|
|
347
|
+
* therefore for "nothing happened".
|
|
348
|
+
*
|
|
349
|
+
* Exported because the card contract (which verdict for which outcome) is worth
|
|
350
|
+
* testing directly: the push-failure branch cannot be produced cheaply against
|
|
351
|
+
* a real remote.
|
|
352
|
+
*/
|
|
353
|
+
export function applyCard(options) {
|
|
354
|
+
const {
|
|
355
|
+
branch, hash, subject, tagCreated, pushed, pushedTag, note, fileCount, totals,
|
|
356
|
+
notes, autoPush, maxFiles = 12,
|
|
357
|
+
} = options
|
|
358
|
+
const lines = []
|
|
359
|
+
const verdict = pushed
|
|
360
|
+
? '✅ **Git 提交并推送成功**'
|
|
361
|
+
: autoPush === true
|
|
362
|
+
? '⚠️ **已提交,但推送失败**'
|
|
363
|
+
: '✅ **Git 提交成功(未推送)**'
|
|
364
|
+
lines.push(`${verdict} · \`${branch}\` · \`${hash ?? '?'}\``)
|
|
365
|
+
lines.push(`信息:${subject}`)
|
|
366
|
+
if (fileCount !== undefined) lines.push(`提交 ${fileCount} 个文件 · +${totals?.added ?? 0} / -${totals?.deleted ?? 0}`)
|
|
367
|
+
|
|
368
|
+
// The per-file notes the commit body carries, so the card shows exactly what
|
|
369
|
+
// went into the repository. One file needs no list: the subject is the note.
|
|
370
|
+
const listed = (notes ?? []).slice(0, maxFiles)
|
|
371
|
+
if (listed.length > 1) {
|
|
372
|
+
for (const item of listed) lines.push(` - ${item.note} · ${item.path}`)
|
|
373
|
+
const hidden = notes.length - listed.length
|
|
374
|
+
if (hidden > 0) lines.push(` - …另有 ${hidden} 个文件`)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
lines.push(`标签:${tagCreated ?? '无'}`)
|
|
378
|
+
lines.push(pushed
|
|
379
|
+
? `推送:已推送${pushedTag ? '(含标签)' : ''}`
|
|
380
|
+
: autoPush === true ? '推送:失败(见下方说明)' : '推送:未推送')
|
|
381
|
+
if (note !== undefined) lines.push(`说明:${note}`)
|
|
382
|
+
return lines.join('\n')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The single entry point behind both the tool and the `/git-commit-push` command.
|
|
387
|
+
*
|
|
388
|
+
* Settings are read here and passed down so that a settings file which exists
|
|
389
|
+
* but does not parse can be REPORTED. Silently committing with the defaults
|
|
390
|
+
* would leave a user staring at a config file that appears to do nothing.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} ctx plugin context
|
|
393
|
+
* @param {object} request
|
|
394
|
+
* @param {string} request.action `prepare` | `apply` | `auto`
|
|
395
|
+
* @param {string} [request.message] a caller-supplied commit message
|
|
396
|
+
* @param {string} [request.tag] a caller-supplied tag name
|
|
397
|
+
* @param {boolean} [request.push] override the autoPush setting
|
|
398
|
+
* @param {'zh' | 'en'} [request.language]
|
|
399
|
+
* @param {string} [request.cwd] explicit repository path
|
|
400
|
+
* @param {object} [exec] tool run context (absent for the slash command)
|
|
401
|
+
* @returns {Promise<ReturnType<typeof valueOf>>}
|
|
402
|
+
*/
|
|
403
|
+
export async function run(ctx, request, exec) {
|
|
404
|
+
const { settings: fileSettings, problem } = await loadSettingsReport()
|
|
405
|
+
// The row config — what DSH's own settings form wrote — outranks the JSON
|
|
406
|
+
// file; see lib/config.js for the precedence and lib/schema.js for the form.
|
|
407
|
+
const settings = resolveSettings({ ui: uiOverrides(ctx, PLUGIN_CONFIG), file: fileSettings })
|
|
408
|
+
const result = await runWithSettings(ctx, request, exec, settings)
|
|
409
|
+
if (problem === undefined) return result
|
|
410
|
+
return {
|
|
411
|
+
...result,
|
|
412
|
+
card: `${result.card}\n配置未生效:${problem}`,
|
|
413
|
+
note: result.note === undefined ? problem : `${result.note};${problem}`,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Everything below `run` works on resolved settings.
|
|
419
|
+
*
|
|
420
|
+
* @param {object} ctx plugin context
|
|
421
|
+
* @param {object} request
|
|
422
|
+
* @param {object} [exec]
|
|
423
|
+
* @param {typeof import('./lib/config.js').DEFAULTS} settings
|
|
424
|
+
*/
|
|
425
|
+
async function runWithSettings(ctx, request, exec, settings) {
|
|
426
|
+
const cwd = request.cwd !== undefined && request.cwd !== '' ? request.cwd : cwdOf(exec)
|
|
427
|
+
const language = request.language ?? settings.defaultLanguage
|
|
428
|
+
const signal = exec?.signal
|
|
429
|
+
|
|
430
|
+
// The plugin's own effective settings, after per-call overrides.
|
|
431
|
+
const effective = { ...settings, autoPush: request.push ?? settings.autoPush }
|
|
432
|
+
|
|
433
|
+
// Both `prepare` and `apply` start from the same survey: `apply` needs the
|
|
434
|
+
// repo root, the branch and the tag evidence, and the calls it makes are
|
|
435
|
+
// in-process reads that cost no tokens.
|
|
436
|
+
const current = await survey({ cwd, language, maxFilesShown: effective.maxFilesShown })
|
|
437
|
+
|
|
438
|
+
if (current.ok !== true) {
|
|
439
|
+
if (current.notRepo === true) {
|
|
440
|
+
const candidates = current.candidates ?? []
|
|
441
|
+
const card = candidates.length === 0
|
|
442
|
+
? '❌ **未初始化 Git**(该项目不是 Git 仓库,未做任何提交)'
|
|
443
|
+
: [
|
|
444
|
+
'⚠️ **当前目录不是 Git 仓库**;其下有这些仓库,请用 cwd 参数指定:',
|
|
445
|
+
...candidates.map(root => ` - ${root}`),
|
|
446
|
+
].join('\n')
|
|
447
|
+
return valueOf({
|
|
448
|
+
ok: false,
|
|
449
|
+
action: request.action,
|
|
450
|
+
card,
|
|
451
|
+
error: candidates.length === 0 ? 'not-a-repository' : 'ambiguous-repository',
|
|
452
|
+
note: candidates.length === 0 ? undefined : '传入 cwd 指向具体仓库后重试',
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
return valueOf({
|
|
456
|
+
ok: false,
|
|
457
|
+
action: request.action,
|
|
458
|
+
card: current.reason === 'clean'
|
|
459
|
+
? 'ℹ️ **没有需要提交的改动**(工作区是干净的)'
|
|
460
|
+
: `⚠️ **无法读取仓库状态**:${current.message ?? '未知原因'}`,
|
|
461
|
+
error: current.reason ?? 'survey-failed',
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (request.action === 'prepare') {
|
|
466
|
+
return valueOf({
|
|
467
|
+
ok: true,
|
|
468
|
+
action: 'prepare',
|
|
469
|
+
card: prepareCard(current, effective),
|
|
470
|
+
root: current.root,
|
|
471
|
+
branch: current.branch,
|
|
472
|
+
files: current.entries.length,
|
|
473
|
+
changes: current.entries.map(entry => ({ status: entry.status, path: entry.path })),
|
|
474
|
+
draft: current.draft.message,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Who writes the body? A caller-supplied message is honored, but when it is a
|
|
479
|
+
// bare subject (no body of its own) the per-file notes are appended rather
|
|
480
|
+
// than replaced: "one message for every file" is exactly what the body is
|
|
481
|
+
// there to avoid, and the caller usually only has an opinion about the sum.
|
|
482
|
+
const supplied = request.message !== undefined && request.message !== ''
|
|
483
|
+
const generated = current.draft.message
|
|
484
|
+
let message = supplied ? request.message : generated
|
|
485
|
+
let commitNotes = supplied ? undefined : current.draft.notes
|
|
486
|
+
if (supplied && !request.message.includes('\n') && current.entries.length > 1) {
|
|
487
|
+
const body = generated.split('\n').slice(1).join('\n').replace(/^\n+/u, '')
|
|
488
|
+
if (body !== '') {
|
|
489
|
+
message = `${request.message}\n\n${body}`
|
|
490
|
+
commitNotes = current.draft.notes
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let committed
|
|
495
|
+
try {
|
|
496
|
+
committed = await doCommit(ctx, exec, {
|
|
497
|
+
settings: effective,
|
|
498
|
+
root: current.root,
|
|
499
|
+
message,
|
|
500
|
+
entries: current.entries,
|
|
501
|
+
})
|
|
502
|
+
} catch (error) {
|
|
503
|
+
// `stageAll` and the numstat read can fail outside the commit itself
|
|
504
|
+
// (a locked index, a path the filesystem refuses). Report it as a card
|
|
505
|
+
// rather than letting the registry surface a raw throw.
|
|
506
|
+
committed = { ok: false, error: brief(error) }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (committed.ok !== true) {
|
|
510
|
+
return valueOf({
|
|
511
|
+
ok: false,
|
|
512
|
+
action: request.action,
|
|
513
|
+
card: `❌ **提交失败**(改动仍留在工作区,未推送)\n原因:${committed.error ?? '未知原因'}`,
|
|
514
|
+
root: current.root,
|
|
515
|
+
branch: current.branch,
|
|
516
|
+
error: 'commit-failed',
|
|
517
|
+
note: committed.error,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const decision = tagDecision(current, effective, request.tag)
|
|
522
|
+
let tagResult
|
|
523
|
+
if (request.tag !== undefined) {
|
|
524
|
+
// An EXPLICIT tag is an instruction, not a suggestion, so it skips the
|
|
525
|
+
// question entirely. Routing it through askTag would let a missing asker —
|
|
526
|
+
// a subagent, a host without the question service, a timed-out wait, or a
|
|
527
|
+
// plain `run()` call — silently drop a tag the caller explicitly asked for.
|
|
528
|
+
tagResult = { decided: request.tag, via: 'explicit' }
|
|
529
|
+
} else if (decision.shouldAsk) {
|
|
530
|
+
tagResult = await askTag(ctx, exec, decision.tag, decision.reasons, effective)
|
|
531
|
+
} else {
|
|
532
|
+
tagResult = { decided: undefined, via: 'skipped' }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const pushResult = await doTagAndPush(ctx, exec, {
|
|
536
|
+
settings: effective,
|
|
537
|
+
root: current.root,
|
|
538
|
+
branch: current.branch,
|
|
539
|
+
tag: tagResult.decided,
|
|
540
|
+
upstream: current.hasUpstream,
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const notes = [tagResult.note, pushResult.note].filter(note => typeof note === 'string' && note !== '')
|
|
544
|
+
|
|
545
|
+
// `ok` answers "did the thing the caller asked for happen", and the commit is
|
|
546
|
+
// the thing that was asked for: it landed, so this is a success even when the
|
|
547
|
+
// push did not. The push outcome is NOT hidden — `pushed` is false, the card
|
|
548
|
+
// says so, and `error` carries the machine-branchable reason — but reporting
|
|
549
|
+
// `ok: false` here would make `/git-commit-push` fail on a commit that is safely in the
|
|
550
|
+
// repository, which is worse than useless.
|
|
551
|
+
const pushFailed = effective.autoPush === true && !pushResult.pushed
|
|
552
|
+
|
|
553
|
+
return valueOf({
|
|
554
|
+
ok: true,
|
|
555
|
+
action: request.action,
|
|
556
|
+
card: applyCard({
|
|
557
|
+
branch: current.branch,
|
|
558
|
+
hash: committed.hash,
|
|
559
|
+
subject: committed.subject ?? message,
|
|
560
|
+
tagCreated: pushResult.tagCreated,
|
|
561
|
+
pushed: pushResult.pushed,
|
|
562
|
+
pushedTag: pushResult.tagPushed,
|
|
563
|
+
note: notes.length > 0 ? notes.join(';') : undefined,
|
|
564
|
+
fileCount: committed.files,
|
|
565
|
+
totals: { added: committed.added ?? 0, deleted: committed.deleted ?? 0 },
|
|
566
|
+
notes: commitNotes,
|
|
567
|
+
autoPush: effective.autoPush,
|
|
568
|
+
maxFiles: effective.maxFilesShown,
|
|
569
|
+
}),
|
|
570
|
+
root: current.root,
|
|
571
|
+
branch: current.branch,
|
|
572
|
+
files: committed.files,
|
|
573
|
+
hash: committed.hash,
|
|
574
|
+
tag: pushResult.tagCreated ?? tagResult.decided,
|
|
575
|
+
tagCreated: pushResult.tagCreated,
|
|
576
|
+
pushed: pushResult.pushed,
|
|
577
|
+
note: notes.length > 0 ? notes.join(';') : undefined,
|
|
578
|
+
error: pushFailed ? pushResult.reason ?? 'push-failed' : undefined,
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The `render` projection: model-facing content is exactly the card. */
|
|
583
|
+
function renderCardContent(_args, value) {
|
|
584
|
+
const text = value?.card !== undefined && value.card !== '' ? value.card : (value?.error ?? 'no result')
|
|
585
|
+
return [{ type: 'text', text }]
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* A hand-written tool definition, in the RAW JSON Schema form.
|
|
590
|
+
*
|
|
591
|
+
* This is deliberate, and it is the one place where being explicit matters most:
|
|
592
|
+
*
|
|
593
|
+
* - `parameters` is the enforced JSON Schema subset (`JsonSchemaNode`), NOT the
|
|
594
|
+
* `ParameterSchemaSpec` authoring DSL. The DSL's per-property
|
|
595
|
+
* `required: true` is compiled by `defineTool` and by nothing else, so a
|
|
596
|
+
* hand-written definition must carry a real object root with a
|
|
597
|
+
* `required: string[]` array — otherwise the model receives a bare property
|
|
598
|
+
* map with no `type: 'object'`, which providers reject.
|
|
599
|
+
* - `output.schema` is validated by the registry at REGISTRATION time, and a
|
|
600
|
+
* `required` that is not an array of strings throws a JsonSchemaError there,
|
|
601
|
+
* taking the whole plugin down before either entry point registers.
|
|
602
|
+
* - `output.render` must be a pure projection of `(args, value)`, which is why
|
|
603
|
+
* the human-readable card travels INSIDE the canonical value.
|
|
604
|
+
*/
|
|
605
|
+
const TOOL_DEFINITION = {
|
|
606
|
+
name: TOOL_NAME,
|
|
607
|
+
description:
|
|
608
|
+
'Commit the current repository and (by default) push it, in one call. '
|
|
609
|
+
// The trigger rule comes FIRST and is deliberately blunt: this tool creates
|
|
610
|
+
// an outward-facing repository change, so it must never be reached for by
|
|
611
|
+
// inference from "the work looks finished".
|
|
612
|
+
+ 'CALL THIS ONLY WHEN THE USER ASKS FOR IT — either they asked you in words to commit and/or push '
|
|
613
|
+
+ '(「提交」「commit」「推送」「push」「推上去」), or they typed the `/git-commit-push` slash command. '
|
|
614
|
+
+ 'NEVER call it on your own initiative: not because you or the user just finished editing files, not because a '
|
|
615
|
+
+ 'task looks complete, not because the session is ending, and not as a tidy-up step. Editing files is not a '
|
|
616
|
+
+ 'request to commit them. If it is unclear whether the user wants a commit, ask first. '
|
|
617
|
+
+ 'action="prepare" returns a compact report of the pending changes — verdict line, file list with per-file line '
|
|
618
|
+
+ 'counts, each file\'s own Conventional-Commits note, status counts, recent commit subjects for tone, and a '
|
|
619
|
+
+ 'rule-generated draft message — WITHOUT spending tokens on the diff itself. Read it, write your own '
|
|
620
|
+
+ 'Conventional-Commits subject, then call action="apply" with that subject as `message`. '
|
|
621
|
+
+ 'MULTI-FILE COMMITS GET ONE NOTE PER FILE: when several files changed, the commit body lists each file with its '
|
|
622
|
+
+ 'own typed note (`- fix(api): correct retry decision · src/api/retry.ts`), and the notes are generated per file '
|
|
623
|
+
+ 'even when you supply only a subject — supplying a message with a body of your own replaces them entirely. '
|
|
624
|
+
+ 'action="auto" commits immediately using the rule-generated message (no extra model turn; use it when the user '
|
|
625
|
+
+ 'asked you to just commit). '
|
|
626
|
+
+ 'The plugin stages the working tree, commits, asks the user about a tag when a version bump, a possible '
|
|
627
|
+
+ 'breaking change or a large changeset warrants one (the question is answered in the UI and costs no tokens), '
|
|
628
|
+
+ 'and pushes, retrying once through `pull --rebase` if the remote moved. '
|
|
629
|
+
+ 'It never modifies .gitignore or git config, never force-pushes, and never bypasses a failing hook. '
|
|
630
|
+
+ 'In a directory that is not a repository it reports the child repositories instead of guessing.',
|
|
631
|
+
parameters: {
|
|
632
|
+
type: 'object',
|
|
633
|
+
additionalProperties: false,
|
|
634
|
+
required: [],
|
|
635
|
+
properties: {
|
|
636
|
+
action: {
|
|
637
|
+
type: 'string',
|
|
638
|
+
enum: ['prepare', 'apply', 'auto'],
|
|
639
|
+
description: 'prepare: survey only. apply: commit with `message`. auto: survey and commit with the rule-generated message.',
|
|
640
|
+
},
|
|
641
|
+
message: {
|
|
642
|
+
type: 'string',
|
|
643
|
+
description: 'The commit message, in Conventional Commits form. Implies action="apply" when action is omitted. '
|
|
644
|
+
+ 'The first line is the subject; later lines become the body and REPLACE the per-file notes the plugin would '
|
|
645
|
+
+ 'generate. Supply only a subject and the per-file notes are still appended for you.',
|
|
646
|
+
},
|
|
647
|
+
tag: {
|
|
648
|
+
type: 'string',
|
|
649
|
+
description: 'Explicit tag name to create and push (e.g. "v1.2.3"). Skips the tag question. Omit to let the plugin decide.',
|
|
650
|
+
},
|
|
651
|
+
push: {
|
|
652
|
+
type: 'boolean',
|
|
653
|
+
description: 'Override the autoPush setting for this call only.',
|
|
654
|
+
},
|
|
655
|
+
language: {
|
|
656
|
+
type: 'string',
|
|
657
|
+
enum: ['zh', 'en'],
|
|
658
|
+
description: 'Language for the rule-generated message (default: the plugin setting, normally zh).',
|
|
659
|
+
},
|
|
660
|
+
cwd: {
|
|
661
|
+
type: 'string',
|
|
662
|
+
description: 'Path of the repository to operate on. Use it when the session working directory is a container '
|
|
663
|
+
+ 'holding several repositories.',
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
output: {
|
|
668
|
+
schema: {
|
|
669
|
+
type: 'object',
|
|
670
|
+
additionalProperties: false,
|
|
671
|
+
required: ['ok', 'action', 'card', 'pushed'],
|
|
672
|
+
properties: {
|
|
673
|
+
ok: { type: 'boolean', description: 'Whether the work the caller asked for actually happened.' },
|
|
674
|
+
action: { type: 'string', description: 'The action that ran: prepare | apply | auto.' },
|
|
675
|
+
card: { type: 'string', description: 'The compact human-readable report for this call.' },
|
|
676
|
+
root: { type: 'string', description: 'Absolute path of the repository that was operated on.' },
|
|
677
|
+
branch: { type: 'string', description: 'Branch the commit landed on.' },
|
|
678
|
+
files: { type: 'number', description: 'Number of changed (prepare) or committed (apply/auto) files.' },
|
|
679
|
+
changes: {
|
|
680
|
+
type: 'array',
|
|
681
|
+
description: 'prepare only: one row per changed path.',
|
|
682
|
+
items: {
|
|
683
|
+
type: 'object',
|
|
684
|
+
additionalProperties: false,
|
|
685
|
+
required: ['status', 'path'],
|
|
686
|
+
properties: {
|
|
687
|
+
status: { type: 'string', description: 'A (added) | M (modified) | D (deleted) | R (renamed) | C (copied) | U (unmerged) | ? (untracked).' },
|
|
688
|
+
path: { type: 'string', description: 'Repository-relative path.' },
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
hash: { type: 'string', description: 'Short hash of the created commit.' },
|
|
693
|
+
draft: { type: 'string', description: 'prepare only: the rule-generated Conventional Commits message.' },
|
|
694
|
+
tag: { type: 'string', description: 'Tag that was created, or the one that was suggested.' },
|
|
695
|
+
tagCreated: { type: 'string', description: 'Tag actually created locally, when one was.' },
|
|
696
|
+
pushed: { type: 'boolean', description: 'Whether the branch reached the remote.' },
|
|
697
|
+
note: { type: 'string', description: 'Anything the caller must know: a rebase recovery, a skipped tag, a hook rejection.' },
|
|
698
|
+
error: { type: 'string', description: 'Machine-branchable failure code when something the caller asked for did not happen.' },
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
render: renderCardContent,
|
|
702
|
+
},
|
|
703
|
+
/**
|
|
704
|
+
* @param {Record<string, unknown>} args
|
|
705
|
+
* @param {{ signal?: AbortSignal, agent?: unknown, callId?: unknown }} exec
|
|
706
|
+
*/
|
|
707
|
+
async execute(args, exec) {
|
|
708
|
+
const action = typeof args.action === 'string'
|
|
709
|
+
? args.action
|
|
710
|
+
: (typeof args.message === 'string' && args.message !== '' ? 'apply' : 'prepare')
|
|
711
|
+
return run(PLUGIN_CONTEXT, {
|
|
712
|
+
action,
|
|
713
|
+
message: typeof args.message === 'string' ? args.message : undefined,
|
|
714
|
+
tag: typeof args.tag === 'string' ? args.tag : undefined,
|
|
715
|
+
push: typeof args.push === 'boolean' ? args.push : undefined,
|
|
716
|
+
language: args.language === 'zh' || args.language === 'en' ? args.language : undefined,
|
|
717
|
+
cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
|
|
718
|
+
}, exec)
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The plugin's own context, captured in `apply` so the hand-written tool
|
|
724
|
+
* definition can reach `ctx.get('userQuestions')` without importing
|
|
725
|
+
* `defineTool` (and therefore without depending on the host's module
|
|
726
|
+
* resolution reaching this package's own imports).
|
|
727
|
+
*/
|
|
728
|
+
let PLUGIN_CONTEXT
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The config DSH parsed from our `Config` schema, captured in `apply`.
|
|
732
|
+
*
|
|
733
|
+
* Held rather than copied because a volatile field is a stable reference: the
|
|
734
|
+
* settings form updates it in place, so reading it per call is what makes a
|
|
735
|
+
* change apply without a remount.
|
|
736
|
+
*/
|
|
737
|
+
let PLUGIN_CONFIG
|
|
738
|
+
|
|
739
|
+
/** Parse the `/git-commit-push` command line into a request. */
|
|
740
|
+
export function parseCommitCommand(rawInput) {
|
|
741
|
+
const tokens = rawInput.trim().split(/\s+/).filter(token => token !== '')
|
|
742
|
+
const request = { action: 'auto' }
|
|
743
|
+
const messageParts = []
|
|
744
|
+
for (const token of tokens) {
|
|
745
|
+
if (token === '--auto') { request.action = 'auto'; continue }
|
|
746
|
+
if (token === '--prepare' || token === '--dry-run') { request.action = 'prepare'; continue }
|
|
747
|
+
if (token === '--no-push') { request.push = false; continue }
|
|
748
|
+
if (token === '--push') { request.push = true; continue }
|
|
749
|
+
if (token === '--en') { request.language = 'en'; continue }
|
|
750
|
+
if (token === '--zh') { request.language = 'zh'; continue }
|
|
751
|
+
if (token.startsWith('--tag=')) { request.tag = token.slice('--tag='.length); continue }
|
|
752
|
+
if (token.startsWith('--cwd=')) { request.cwd = token.slice('--cwd='.length); continue }
|
|
753
|
+
messageParts.push(token)
|
|
754
|
+
}
|
|
755
|
+
const message = messageParts.join(' ').trim()
|
|
756
|
+
if (message !== '') {
|
|
757
|
+
request.message = message
|
|
758
|
+
// An explicit message with no explicit verb means "commit this".
|
|
759
|
+
if (!tokens.includes('--prepare') && !tokens.includes('--dry-run')) request.action = 'apply'
|
|
760
|
+
}
|
|
761
|
+
return request
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Register the slash command once the commands service is available.
|
|
766
|
+
*
|
|
767
|
+
* `commands` is a strictly optional capability, so it is deliberately NOT in
|
|
768
|
+
* the static `inject` list: putting it there would block the whole plugin (and
|
|
769
|
+
* therefore the `git_commit_push` tool) on a host that has no command surface. The
|
|
770
|
+
* scoped `ctx.inject` waits for it in the background instead, and the tool works
|
|
771
|
+
* either way. `commands` is the common case — this profile mounts it.
|
|
772
|
+
*/
|
|
773
|
+
function registerCommand(ctx) {
|
|
774
|
+
ctx.inject(['commands'], (sctx) => {
|
|
775
|
+
const commands = sctx.get('commands')
|
|
776
|
+
if (commands === undefined) return
|
|
777
|
+
commands.register({
|
|
778
|
+
name: COMMAND_NAME,
|
|
779
|
+
description: '提交并推送当前项目(Conventional Commits),可用 --prepare 仅预览、--no-push 不推送',
|
|
780
|
+
input: { hint: '[提交信息 | --prepare | --en | --no-push | --tag=vX.Y.Z]' },
|
|
781
|
+
handler: async (invocation) => {
|
|
782
|
+
const request = parseCommitCommand(invocation.rawInput ?? '')
|
|
783
|
+
const exec = { signal: invocation.signal, agent: invocation.agent, callId: invocation.commandId }
|
|
784
|
+
try {
|
|
785
|
+
const result = await run(ctx, request, exec)
|
|
786
|
+
if (result.ok) return { kind: 'success', text: result.card }
|
|
787
|
+
return { kind: 'error', text: result.card !== '' ? result.card : '提交未完成' }
|
|
788
|
+
} catch (error) {
|
|
789
|
+
return { kind: 'error', text: brief(error) }
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Register the skill this package ships (SKILL.md) as an embedded runtime skill.
|
|
798
|
+
*
|
|
799
|
+
* `skills` is an optional capability exactly like `commands`, so it is not in
|
|
800
|
+
* the static `inject` list: a host without a skill registry must still get the
|
|
801
|
+
* tool. The registry draws the ordering — a project-level skill with the same
|
|
802
|
+
* name outranks this runtime registration, so a user who keeps their own
|
|
803
|
+
* `git-commit-push` skill in the workspace keeps winning.
|
|
804
|
+
*
|
|
805
|
+
* A registration failure is logged, never thrown: losing the skill costs the
|
|
806
|
+
* model its procedure, losing the tool costs the user the feature.
|
|
807
|
+
*/
|
|
808
|
+
function registerSkill(ctx) {
|
|
809
|
+
ctx.inject(['skills'], (sctx) => {
|
|
810
|
+
const skills = sctx.get('skills')
|
|
811
|
+
if (skills === undefined) return
|
|
812
|
+
const skill = skillDefinition()
|
|
813
|
+
if (skill === undefined) {
|
|
814
|
+
ctx.logger?.warn?.(`${name}: SKILL.md is missing or unusable; the git-commit-push skill was not registered`)
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
skills.register(skill)
|
|
819
|
+
} catch (error) {
|
|
820
|
+
ctx.logger?.warn?.(`${name}: skill "${skill.name}" was not registered — ${brief(error)}`)
|
|
821
|
+
}
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Re-implementation of the registry's schema assertions, so a test can prove
|
|
827
|
+
* the definition would survive `ctx.tools.register` WITHOUT needing a live host.
|
|
828
|
+
*
|
|
829
|
+
* The two rules that actually bite a hand-written definition:
|
|
830
|
+
* - `required` must be an ARRAY OF STRINGS. The `required: true` per-property
|
|
831
|
+
* form belongs to the `defineTool` authoring DSL and is rejected here, at
|
|
832
|
+
* registration time, with a JsonSchemaError that takes the plugin down.
|
|
833
|
+
* - every node's `type` must be one of the supported scalars/containers, and
|
|
834
|
+
* `enum`/`const` are only valid on scalars.
|
|
835
|
+
*
|
|
836
|
+
* @returns {string[]} violations; empty means registration would succeed
|
|
837
|
+
*/
|
|
838
|
+
export function schemaProblems(schema, path = 'schema') {
|
|
839
|
+
const SUPPORTED_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
|
|
840
|
+
const problems = []
|
|
841
|
+
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
|
842
|
+
return [`${path} must be a plain object`]
|
|
843
|
+
}
|
|
844
|
+
if (schema.type !== undefined && !SUPPORTED_TYPES.includes(schema.type)) {
|
|
845
|
+
problems.push(`${path}.type "${String(schema.type)}" is not supported`)
|
|
846
|
+
}
|
|
847
|
+
if (schema.required !== undefined) {
|
|
848
|
+
if (!Array.isArray(schema.required) || schema.required.some(entry => typeof entry !== 'string')) {
|
|
849
|
+
// The exact failure mode this guards: `required: true` from the DSL.
|
|
850
|
+
problems.push(`${path}.required must be an array of strings`)
|
|
851
|
+
} else if (schema.properties !== undefined) {
|
|
852
|
+
for (const name of schema.required) {
|
|
853
|
+
if (!(name in schema.properties)) problems.push(`${path}.required names "${name}", which is not declared`)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
for (const [name, child] of Object.entries(schema.properties ?? {})) {
|
|
858
|
+
problems.push(...schemaProblems(child, `${path}.properties.${name}`))
|
|
859
|
+
}
|
|
860
|
+
if (schema.items !== undefined) problems.push(...schemaProblems(schema.items, `${path}.items`))
|
|
861
|
+
for (const [index, branch] of (schema.oneOf ?? []).entries()) {
|
|
862
|
+
problems.push(...schemaProblems(branch, `${path}.oneOf[${String(index)}]`))
|
|
863
|
+
}
|
|
864
|
+
return problems
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Everything the registry would reject about this tool definition. */
|
|
868
|
+
export function toolDefinitionProblems() {
|
|
869
|
+
const problems = []
|
|
870
|
+
if (typeof TOOL_DEFINITION.name !== 'string' || TOOL_DEFINITION.name === '') problems.push('name is missing')
|
|
871
|
+
if (typeof TOOL_DEFINITION.description !== 'string' || TOOL_DEFINITION.description === '') problems.push('description is missing')
|
|
872
|
+
if (typeof TOOL_DEFINITION.output?.render !== 'function') problems.push('output.render is missing')
|
|
873
|
+
problems.push(...schemaProblems(TOOL_DEFINITION.parameters, 'parameters'))
|
|
874
|
+
if (TOOL_DEFINITION.output?.schema === undefined) problems.push('output.schema is missing')
|
|
875
|
+
else problems.push(...schemaProblems(TOOL_DEFINITION.output.schema, 'output.schema'))
|
|
876
|
+
return problems
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Plugin entry point.
|
|
881
|
+
*
|
|
882
|
+
* @param {any} ctx host plugin context
|
|
883
|
+
* @param {any} [config] the row config DSH parsed from our `Config` schema
|
|
884
|
+
*/
|
|
885
|
+
export function apply(ctx, config) {
|
|
886
|
+
PLUGIN_CONTEXT = ctx
|
|
887
|
+
// Kept rather than read once: the settings form writes volatile fields into
|
|
888
|
+
// these running references, so every call reads the current values.
|
|
889
|
+
PLUGIN_CONFIG = config
|
|
890
|
+
// Fail loudly and specifically here rather than with an opaque registry error:
|
|
891
|
+
// a malformed definition is an authoring bug the host reports once, at boot.
|
|
892
|
+
const problems = toolDefinitionProblems()
|
|
893
|
+
if (problems.length > 0) {
|
|
894
|
+
throw new Error(`${name}: invalid tool definition — ${problems.join('; ')}`)
|
|
895
|
+
}
|
|
896
|
+
ctx.tools.register(TOOL_DEFINITION)
|
|
897
|
+
registerCommand(ctx)
|
|
898
|
+
registerSkill(ctx)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Diagnostics: the settings files this plugin reads, and the self-check hooks. */
|
|
902
|
+
export { CONFIG_PATH, userConfigPath, configCandidates, TOOL_DEFINITION }
|
|
903
|
+
export { CONFIG as Config }
|
|
904
|
+
export { FIELDS as CONFIG_FIELDS, resolveSettings, uiOverrides } from './lib/config.js'
|
|
905
|
+
export { buildConfigSchema, loadSchemaLibrary } from './lib/schema.js'
|
|
906
|
+
export { skillDefinition, parseSkillFile, SKILL_NAME, SKILL_PATH, SKILL_SOURCE } from './lib/skill.js'
|
|
907
|
+
export { survey } from './lib/survey.js'
|
|
908
|
+
export { push, pushAfterRebase } from './lib/git.js'
|