dsh-github-flow 0.6.0 → 0.7.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.
Files changed (95) hide show
  1. package/README.en.md +102 -15
  2. package/README.md +105 -15
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +67 -14
  5. package/lib/api/route-helpers.d.ts +19 -0
  6. package/lib/api/route-helpers.js +54 -0
  7. package/lib/api/routes-actions.js +126 -2
  8. package/lib/api/routes-lifecycle.js +8 -1
  9. package/lib/api/routes-overview.d.ts +26 -0
  10. package/lib/api/routes-overview.js +276 -34
  11. package/lib/api/routes-proxy.js +8 -1
  12. package/lib/api/routes.d.ts +11 -1
  13. package/lib/api/routes.js +15 -2
  14. package/lib/cache.d.ts +0 -21
  15. package/lib/cache.js +0 -37
  16. package/lib/client.d.ts +2 -1
  17. package/lib/client.js +2108 -658
  18. package/lib/destructive.d.ts +59 -0
  19. package/lib/destructive.js +285 -0
  20. package/lib/executor/args-guard.d.ts +17 -0
  21. package/lib/executor/args-guard.js +23 -0
  22. package/lib/executor/failure-anchors.d.ts +52 -0
  23. package/lib/executor/failure-anchors.js +116 -0
  24. package/lib/executor/pr-review.d.ts +95 -0
  25. package/lib/executor/pr-review.js +175 -0
  26. package/lib/executor/run-log.d.ts +36 -0
  27. package/lib/executor/run-log.js +78 -0
  28. package/lib/executor.d.ts +204 -3
  29. package/lib/executor.js +670 -28
  30. package/lib/github-service.d.ts +1 -1
  31. package/lib/main.d.ts +92 -18
  32. package/lib/main.js +186 -95
  33. package/lib/renderers/common.d.ts +0 -3
  34. package/lib/renderers/common.js +8 -44
  35. package/lib/renderers/markdown.d.ts +1 -0
  36. package/lib/renderers/markdown.js +1 -0
  37. package/lib/renderers/pr.js +26 -1
  38. package/lib/renderers/task.d.ts +9 -0
  39. package/lib/renderers/task.js +120 -0
  40. package/lib/review-rules.d.ts +60 -0
  41. package/lib/review-rules.js +204 -0
  42. package/lib/shared/state-badges.d.ts +42 -0
  43. package/lib/shared/state-badges.js +102 -0
  44. package/lib/task-primitives.d.ts +109 -0
  45. package/lib/task-primitives.js +194 -0
  46. package/lib/tool-tiers.d.ts +57 -0
  47. package/lib/tool-tiers.js +168 -0
  48. package/lib/tools/agent-task.d.ts +4 -0
  49. package/lib/tools/agent-task.js +22 -9
  50. package/lib/tools/api.d.ts +4 -0
  51. package/lib/tools/api.js +48 -12
  52. package/lib/tools/body-input.d.ts +58 -0
  53. package/lib/tools/body-input.js +123 -0
  54. package/lib/tools/cache.js +42 -40
  55. package/lib/tools/codespace.js +4 -12
  56. package/lib/tools/discussion.d.ts +4 -0
  57. package/lib/tools/discussion.js +33 -17
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +65 -13
  61. package/lib/tools/issue.d.ts +4 -0
  62. package/lib/tools/issue.js +37 -38
  63. package/lib/tools/label.js +4 -15
  64. package/lib/tools/pr.d.ts +51 -0
  65. package/lib/tools/pr.js +204 -46
  66. package/lib/tools/project.js +4 -13
  67. package/lib/tools/release.d.ts +4 -0
  68. package/lib/tools/release.js +26 -26
  69. package/lib/tools/repo.js +4 -4
  70. package/lib/tools/ruleset.js +9 -6
  71. package/lib/tools/run.js +76 -24
  72. package/lib/tools/secret.js +11 -17
  73. package/lib/tools/skill.js +18 -22
  74. package/lib/tools/status.js +4 -4
  75. package/lib/tools/task.d.ts +223 -0
  76. package/lib/tools/task.js +464 -0
  77. package/lib/types.d.ts +183 -1
  78. package/lib/ui/commit-modal.js +108 -4
  79. package/lib/ui/diff-utils.d.ts +51 -0
  80. package/lib/ui/diff-utils.js +155 -0
  81. package/lib/ui/file-diff-modal.d.ts +33 -0
  82. package/lib/ui/file-diff-modal.js +250 -0
  83. package/lib/ui/help-modal.d.ts +1 -1
  84. package/lib/ui/help-modal.js +24 -12
  85. package/lib/ui/issue-modal.js +7 -3
  86. package/lib/ui/pr-modal.js +50 -11
  87. package/lib/ui/run-modal.d.ts +26 -0
  88. package/lib/ui/run-modal.js +486 -0
  89. package/lib/ui/styles.d.ts +1 -1
  90. package/lib/ui/styles.js +376 -44
  91. package/lib/ui/sync-settings-modal.js +1 -1
  92. package/lib/ui/workspace-panel.js +78 -23
  93. package/lib/version.d.ts +1 -1
  94. package/lib/version.js +1 -1
  95. package/package.json +9 -2
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GitHub 正文上限(Issue/PR/评论/讨论统一口径 65,536 字符)。
3
+ * 抽成常量而不是散落字面量:这是上游约束,将来调整只改一处。
4
+ */
5
+ export declare const GITHUB_BODY_MAX_CHARS = 65536;
6
+ /**
7
+ * 单个正文文件的字节硬上限(4MB)。
8
+ * 字符上限只管正文语义,管不住「模型指了一个 200MB 日志文件」——先按字节拦住,
9
+ * 避免 readFileSync 把整个文件拉进内存再判定。
10
+ */
11
+ export declare const BODY_FILE_MAX_BYTES: number;
12
+ /** 入参归一化的产出:argv 片段 + 需要注入的 stdin + 权威文本 */
13
+ export interface BodySource {
14
+ /** 交给 gh 的 argv 片段:`[]` | `[flag, text]` | `[fileFlag, '-']` */
15
+ argv: string[];
16
+ /** `[fileFlag, '-']` 时需要经 stdin 注入的正文;其余情况为 undefined */
17
+ stdin?: string;
18
+ /** 权威正文(REST/GraphQL 通道直接用它;调用方做必填判定也看它) */
19
+ text: string;
20
+ /** `text` 的字符数(按 UTF-16 单元近似 GitHub 的字符口径) */
21
+ chars: number;
22
+ fromFile: boolean;
23
+ /** 文件来源时解析后的绝对路径(仅用于回执与错误诊断) */
24
+ file?: string;
25
+ }
26
+ export interface BodySourceOptions {
27
+ /** 相对路径的解析基准(工具层传 `executor.resolveExecScope` 得到的工作区) */
28
+ cwd: string;
29
+ /** 内联 flag,缺省 `--body`(discussion 用 `-b`) */
30
+ flag?: string;
31
+ /** 文件 flag,缺省 `--body-file`(release 用 `--notes-file`,api/agent-task 用各自 flag) */
32
+ fileFlag?: string;
33
+ /** 内联字段名,仅用于错误文案(缺省 `body`) */
34
+ inlineField?: string;
35
+ /** 文件字段名,仅用于错误文案(缺省 `body_file`) */
36
+ fileField?: string;
37
+ /** 无正文时仍下发 `[flag, '']`:create 需要它保持非交互(否则 gh 会打开编辑器) */
38
+ keepEmptyInline?: boolean;
39
+ /** 字符上限覆盖;传 0 关闭字符校验(`gh api --input` 的请求体不受正文口径约束) */
40
+ maxChars?: number;
41
+ }
42
+ /**
43
+ * 解析正文来源。
44
+ *
45
+ * 语义表(与计划文档一致,调用方不再各自二次决策):
46
+ * | 输入 | argv | stdin |
47
+ * | --- | --- | --- |
48
+ * | `file` 有值且文件非空 | `[fileFlag, '-']` | 文件正文 |
49
+ * | `file` 有值但文件为空 | 同「都为空」 | — |
50
+ * | `inline` 非空 | `[flag, inline]` | — |
51
+ * | 都为空 + keepEmptyInline | `[flag, '']` | — |
52
+ * | 都为空 | `[]` | — |
53
+ * | 两者都非空 | 抛错(不做静默优先级) | — |
54
+ */
55
+ export declare function resolveBodySource(input: {
56
+ inline?: unknown;
57
+ file?: unknown;
58
+ }, options?: BodySourceOptions): BodySource;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 「正文/长文本来自本地文件」的统一入参解析(纯函数,零 gh 依赖)。
3
+ *
4
+ * 为什么需要它:Agent 的典型工作流是**先把报告写成 md 文件**,再提交成 Issue / PR 评论 /
5
+ * 讨论 / 发版说明。改造前这些入参只有字符串 `body`,模型只能把整篇正文搬进参数——
6
+ * 既烧上下文,又会在 Windows 约 32K 的命令行上限处炸掉(`E2BIG/EINVAL`,错误还很难懂)。
7
+ * 于是 Agent 会绕开插件直接用 `gh issue create -F report.md`,插件的确认闸门/错误分类/渲染
8
+ * 全部被跳过。
9
+ *
10
+ * 为什么不让 gh 自己去读文件(`--body-file <path>`):`pr review` 的行级评注走 REST
11
+ * (`POST /pulls/{n}/reviews`),那条通道根本没有 flag,只能插件侧读文件后塞 JSON。
12
+ * 两套通道各写一份,必然漂移成「带 comments 时 body_file 静默失效」。因此统一为:
13
+ * **插件读一次文件** →
14
+ * - CLI 通道:`[fileFlag, '-']` + `stdin`(`--body-file -` 由 gh 读 stdin,无 argv 长度上限);
15
+ * - REST/GraphQL 通道:直接用 `text`。
16
+ *
17
+ * 兼容性纪律:内联来源的 argv 必须与本模块引入前**逐字节一致**(`[flag, text]`),
18
+ * 新通道只在「传了 `*_file`」时才改变 argv。
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ /**
23
+ * GitHub 正文上限(Issue/PR/评论/讨论统一口径 65,536 字符)。
24
+ * 抽成常量而不是散落字面量:这是上游约束,将来调整只改一处。
25
+ */
26
+ export const GITHUB_BODY_MAX_CHARS = 65_536;
27
+ /**
28
+ * 单个正文文件的字节硬上限(4MB)。
29
+ * 字符上限只管正文语义,管不住「模型指了一个 200MB 日志文件」——先按字节拦住,
30
+ * 避免 readFileSync 把整个文件拉进内存再判定。
31
+ */
32
+ export const BODY_FILE_MAX_BYTES = 4 * 1024 * 1024;
33
+ /** 严格 UTF-8 解码器:GBK/ANSI 存的中文 md 会抛错,而不是静默产出乱码 */
34
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
35
+ function emptyResult(flag, keepEmptyInline) {
36
+ return { argv: keepEmptyInline ? [flag, ''] : [], text: '', chars: 0, fromFile: false };
37
+ }
38
+ function inlineResult(flag, text) {
39
+ return { argv: [flag, text], text, chars: text.length, fromFile: false };
40
+ }
41
+ /**
42
+ * 解析正文来源。
43
+ *
44
+ * 语义表(与计划文档一致,调用方不再各自二次决策):
45
+ * | 输入 | argv | stdin |
46
+ * | --- | --- | --- |
47
+ * | `file` 有值且文件非空 | `[fileFlag, '-']` | 文件正文 |
48
+ * | `file` 有值但文件为空 | 同「都为空」 | — |
49
+ * | `inline` 非空 | `[flag, inline]` | — |
50
+ * | 都为空 + keepEmptyInline | `[flag, '']` | — |
51
+ * | 都为空 | `[]` | — |
52
+ * | 两者都非空 | 抛错(不做静默优先级) | — |
53
+ */
54
+ export function resolveBodySource(input, options = { cwd: process.cwd() }) {
55
+ const flag = options.flag ?? '--body';
56
+ const fileFlag = options.fileFlag ?? '--body-file';
57
+ const inlineField = options.inlineField ?? 'body';
58
+ const fileField = options.fileField ?? 'body_file';
59
+ const maxChars = options.maxChars ?? GITHUB_BODY_MAX_CHARS;
60
+ const rawInline = input.inline;
61
+ if (rawInline !== undefined && rawInline !== null && typeof rawInline !== 'string') {
62
+ throw new Error(`${inlineField} 必须是字符串。`);
63
+ }
64
+ // 不做 trim:内联正文要原样下发,保持与改造前逐字节一致
65
+ const inline = typeof rawInline === 'string' ? rawInline : '';
66
+ const rawFile = input.file;
67
+ if (rawFile === undefined || rawFile === null) {
68
+ return inline.length > 0 ? inlineResult(flag, inline) : emptyResult(flag, options.keepEmptyInline);
69
+ }
70
+ if (typeof rawFile !== 'string') {
71
+ throw new Error(`${fileField} 必须是本地文件路径字符串(绝对路径或相对当前工作区的路径)。`);
72
+ }
73
+ const fileInput = rawFile.trim();
74
+ // 空串视同未提供:调用方(含契约测试的样例表)用一个空串表达「这条路不走」
75
+ if (!fileInput) {
76
+ return inline.length > 0 ? inlineResult(flag, inline) : emptyResult(flag, options.keepEmptyInline);
77
+ }
78
+ // 二选一而非优先级:模型拿到一次明确失败,远好过「不知道哪份真正生效」
79
+ if (inline.length > 0) {
80
+ throw new Error(`${inlineField} 与 ${fileField} 同时提供:请二选一(长正文用 ${fileField},短文本用 ${inlineField})。`);
81
+ }
82
+ const abs = path.isAbsolute(fileInput) ? fileInput : path.resolve(options.cwd, fileInput);
83
+ let stat;
84
+ try {
85
+ stat = fs.statSync(abs);
86
+ }
87
+ catch {
88
+ throw new Error(`${fileField} 指向的文件不存在: "${abs}"(输入: "${fileInput}")。`);
89
+ }
90
+ if (!stat.isFile()) {
91
+ throw new Error(`${fileField} 必须是普通文件,当前指向的不是文件: "${abs}"。`);
92
+ }
93
+ if (stat.size > BODY_FILE_MAX_BYTES) {
94
+ // 用字节数报错而不是 toFixed(1) 的 MB:4MB+1 字节会被四舍五入显示成「4.0MB 超过 4MB」,
95
+ // 看上去像插件算错了,反而增加排查成本
96
+ throw new Error(`${fileField} 文件过大: ${stat.size} 字节,超过 ${BODY_FILE_MAX_BYTES} 字节(4MB)上限。`);
97
+ }
98
+ const buf = fs.readFileSync(abs);
99
+ // 二进制先判、再解码:PNG 之类在 UTF-8 解码阶段会报「不是合法 UTF-8」并给出 GBK 另存建议,
100
+ // 对二进制文件是误导;NUL 字节是更准确的判据(文本文件不会有)
101
+ if (buf.includes(0)) {
102
+ throw new Error(`${fileField} 内容是二进制(含 NUL 字节),不能作为正文: "${abs}"。`);
103
+ }
104
+ let text;
105
+ try {
106
+ text = strictUtf8.decode(buf);
107
+ }
108
+ catch {
109
+ throw new Error(`${fileField} 不是合法 UTF-8 文本: "${abs}"。Windows 上常见 GBK/ANSI 编码,请另存为 UTF-8 后重试。`);
110
+ }
111
+ // 解码头本身会剥 BOM(TextDecoder 的 ignoreBOM 默认 false);这里显式再剥一次是防御:
112
+ // 一旦有人把上面改成 ignoreBOM: true,正文首字符就会变成零宽 BOM 字符
113
+ if (text.charCodeAt(0) === 0xfeff)
114
+ text = text.slice(1);
115
+ const chars = text.length;
116
+ if (maxChars > 0 && chars > maxChars) {
117
+ throw new Error(`${fileField} 正文过长: ${chars} 字符,超过上限 ${maxChars} 字符。请拆分或精简后再提交。`);
118
+ }
119
+ // 空文件不进 stdin 通道:不依赖 gh 对空 stdin 的处理,语义与「没正文」完全一致
120
+ if (chars === 0)
121
+ return emptyResult(flag, options.keepEmptyInline);
122
+ return { argv: [fileFlag, '-'], stdin: text, text, chars, fromFile: true, file: abs };
123
+ }
@@ -1,3 +1,4 @@
1
+ import { wrapWithGate } from '../destructive.js';
1
2
  import { renderCacheMarkdown } from '../renderers/markdown.js';
2
3
  function normalizeOutput(data) {
3
4
  if (data && typeof data === 'object' && !Array.isArray(data)) {
@@ -9,9 +10,9 @@ function normalizeOutput(data) {
9
10
  return { result: data !== undefined && data !== null ? data : '' };
10
11
  }
11
12
  export function createCacheTool(executor) {
12
- return {
13
+ return wrapWithGate({
13
14
  name: 'github_cache',
14
- description: '管理 GitHub Actions 构建缓存 (Caches):查看缓存列表与占用容量、按 ID 或 Key 删除指定缓存、以及全量清空仓库缓存。全量清空不可逆,需 confirm: true。',
15
+ description: '管理 GitHub Actions 构建缓存 (Caches):查看缓存列表与占用容量、按 ID 或 Key 删除指定缓存、以及全量清空仓库缓存。删除不可逆,delete 的三种方式(cache_id / key / all)均需 confirm: true。',
15
16
  parameters: {
16
17
  type: 'object',
17
18
  properties: {
@@ -56,7 +57,7 @@ export function createCacheTool(executor) {
56
57
  },
57
58
  confirm: {
58
59
  type: 'boolean',
59
- description: '高危破坏性操作确认标志(delete 当 all: true 时必需)。未传 true 时输出影响评估报告并拦截。',
60
+ description: '高危破坏性操作确认标志(delete 的三态 cache_id / key / all 均必需)。未传 true 时输出影响评估报告并拦截。',
60
61
  },
61
62
  },
62
63
  required: ['action'],
@@ -68,8 +69,7 @@ export function createCacheTool(executor) {
68
69
  },
69
70
  },
70
71
  async execute(args, execContext) {
71
- const cwd = execContext?.cwd || process.cwd();
72
- const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
72
+ const { cwd, sessionId } = executor.resolveExecScope(execContext);
73
73
  let repoTarget = args.repo;
74
74
  if (!repoTarget) {
75
75
  const meta = await executor.getRepoMetadata(cwd, sessionId);
@@ -120,19 +120,26 @@ export function createCacheTool(executor) {
120
120
  return normalizeOutput(rawData || []);
121
121
  }
122
122
  case 'delete': {
123
- // 1. 全量清空分支
124
- if (args.all === true) {
125
- if (args.confirm !== true) {
126
- return {
127
- status: 'AWAITING_CONFIRMATION',
128
- action: 'delete',
129
- all: true,
130
- repo: repoTarget,
131
- warning: `⚠️ 高危破坏性操作:即将清空仓库 ${repoTarget} 的所有 GitHub Actions 构建缓存`,
132
- impact: `清空缓存不可逆,会导致后续流水线构建无法复用依赖与构建产物,显著增加 CI 运行耗时。`,
133
- requiredAction: `如确认彻底清空,请在参数中附加 \`confirm: true\` 重新调用。示例:\`github_cache(action: 'delete', all: true, confirm: true)\``,
134
- };
135
- }
123
+ // ---- 第一段:选择器归一化 + 形态校验(在任何执行之前)----
124
+ const hasAll = args.all === true;
125
+ const cacheIdStr = args.cache_id !== undefined && args.cache_id !== null ? String(args.cache_id).trim() : '';
126
+ const keyStr = args.key !== undefined && args.key !== null ? String(args.key).trim() : '';
127
+ const hasId = cacheIdStr !== '';
128
+ const hasKey = keyStr !== '';
129
+ if (!hasAll && !hasId && !hasKey) {
130
+ throw new Error('删除缓存时必须指定 `cache_id`、`key` 或 `all: true`(三条删除分支均需配合 confirm: true)。');
131
+ }
132
+ // 形态校验必须先于执行:不校验时 `cache_id: "--all"` 会被当作 flag 透传,
133
+ // 等价于清空全仓缓存;而 `gh cache delete --all` 自身没有二次确认,绕过即秒清。
134
+ if (hasId && !/^\d+$/.test(cacheIdStr)) {
135
+ throw new Error(`cache_id 必须是纯数字的缓存 ID(实际收到 "${cacheIdStr}")。按 key 删除请改用 key 参数,全量清空请用 all: true。`);
136
+ }
137
+ if (hasKey && (keyStr.startsWith('-') || keyStr.length > 512)) {
138
+ throw new Error('key 不能以 - 开头(防被当作命令行 flag),且长度不得超过 512 字符。');
139
+ }
140
+ // ---- 执行段(argv 形状与改动前保持一致)----
141
+ // confirm 闸门由 wrapWithGate 在工厂入口统一处理,三条分支共用同一套语义。
142
+ if (hasAll) {
136
143
  const delCmd = ['cache', 'delete', '--all', '--succeed-on-no-caches', ...repoArgs];
137
144
  if (args.ref) {
138
145
  delCmd.push('-r', String(args.ref));
@@ -147,40 +154,35 @@ export function createCacheTool(executor) {
147
154
  detail: res.rawOutput || '',
148
155
  };
149
156
  }
150
- // 2. 按 cache_id 删除
151
- if (args.cache_id !== undefined && args.cache_id !== null && String(args.cache_id).trim() !== '') {
152
- const delCmd = ['cache', 'delete', String(args.cache_id), ...repoArgs];
157
+ if (hasId) {
158
+ const delCmd = ['cache', 'delete', cacheIdStr, ...repoArgs];
153
159
  const res = await executor.run(delCmd, runOpts);
154
160
  if (!res.ok)
155
161
  throw new Error(res.error);
156
162
  return {
157
163
  success: true,
158
- cache_id: String(args.cache_id),
159
- message: `🗑️ 成功删除仓库 ${repoTarget} 中 ID 为 "${args.cache_id}" 的 Actions 缓存`,
164
+ cache_id: cacheIdStr,
165
+ message: `🗑️ 成功删除仓库 ${repoTarget} 中 ID 为 "${cacheIdStr}" 的 Actions 缓存`,
160
166
  detail: res.rawOutput || '',
161
167
  };
162
168
  }
163
- // 3. 按 key 删除
164
- if (args.key !== undefined && args.key !== null && String(args.key).trim() !== '') {
165
- const delCmd = ['cache', 'delete', String(args.key), ...repoArgs];
166
- if (args.ref) {
167
- delCmd.push('-r', String(args.ref));
168
- }
169
- const res = await executor.run(delCmd, runOpts);
170
- if (!res.ok)
171
- throw new Error(res.error);
172
- return {
173
- success: true,
174
- key: String(args.key),
175
- message: `🗑️ 成功删除仓库 ${repoTarget} 中 Key 为 "${args.key}" 的 Actions 缓存` + (args.ref ? ` (Ref: ${args.ref})` : ''),
176
- detail: res.rawOutput || '',
177
- };
169
+ const delCmd = ['cache', 'delete', keyStr, ...repoArgs];
170
+ if (args.ref) {
171
+ delCmd.push('-r', String(args.ref));
178
172
  }
179
- throw new Error('删除缓存时必须指定 `cache_id`、`key` 或 `all: true`(全量删除需配合 confirm: true)。');
173
+ const res = await executor.run(delCmd, runOpts);
174
+ if (!res.ok)
175
+ throw new Error(res.error);
176
+ return {
177
+ success: true,
178
+ key: keyStr,
179
+ message: `🗑️ 成功删除仓库 ${repoTarget} 中 Key 为 "${keyStr}" 的 Actions 缓存` + (args.ref ? ` (Ref: ${args.ref})` : ''),
180
+ detail: res.rawOutput || '',
181
+ };
180
182
  }
181
183
  default:
182
184
  throw new Error(`不支持的 cache 操作: "${args.action}"。可选值: list, delete。`);
183
185
  }
184
186
  },
185
- };
187
+ });
186
188
  }
@@ -1,3 +1,4 @@
1
+ import { wrapWithGate } from '../destructive.js';
1
2
  import { renderCodespaceMarkdown } from '../renderers/markdown.js';
2
3
  import { checkScopeRequired } from './project.js';
3
4
  function normalizeOutput(data) {
@@ -12,7 +13,7 @@ function normalizeOutput(data) {
12
13
  const CODESPACE_LIST_FIELDS = 'name,displayName,repository,owner,state,gitStatus,lastUsedAt,createdAt,machineName';
13
14
  const CODESPACE_VIEW_FIELDS = 'name,displayName,repository,owner,state,gitStatus,lastUsedAt,createdAt,machineName,devcontainerPath,recentFolders,idleTimeoutMinutes';
14
15
  export function createCodespaceTool(executor) {
15
- return {
16
+ return wrapWithGate({
16
17
  name: 'github_codespace',
17
18
  description: '管理 GitHub Codespaces 云端远程开发环境:查阅实例列表与运行状态、检视实例配置详情、停止运行中的环境防计费、安全销毁实例。未授权时提供平台感知自愈命令。',
18
19
  parameters: {
@@ -53,8 +54,7 @@ export function createCodespaceTool(executor) {
53
54
  },
54
55
  },
55
56
  async execute(args, execContext) {
56
- const cwd = execContext?.cwd || process.cwd();
57
- const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
57
+ const { cwd, sessionId } = executor.resolveExecScope(execContext);
58
58
  const runOpts = { cwd, sessionId };
59
59
  switch (args.action) {
60
60
  case 'list': {
@@ -123,14 +123,6 @@ export function createCodespaceTool(executor) {
123
123
  throw new Error('delete 操作必须提供 codespace 名称');
124
124
  }
125
125
  const cleanName = args.codespace.trim();
126
- if (args.confirm !== true) {
127
- return {
128
- status: 'AWAITING_CONFIRMATION',
129
- warning: `⚠️ 销毁确认拦截:你即将永久删除云端开发环境「${cleanName}」。`,
130
- impact: '物理销毁 Codespace 将永久释放云端计算资源,环境中任何未提交并推送到远端 Git 的临时修改都将无法找回。',
131
- requiredAction: '如确认销毁该实例,请核对环境名称并在参数中附加 `confirm: true` 重新调用。',
132
- };
133
- }
134
126
  const deleteCmd = ['codespace', 'delete', '-c', cleanName, '-f'];
135
127
  if (args.org)
136
128
  deleteCmd.push('-o', String(args.org).trim());
@@ -150,5 +142,5 @@ export function createCodespaceTool(executor) {
150
142
  throw new Error(`未知的 Codespace 操作: ${args.action}`);
151
143
  }
152
144
  },
153
- };
145
+ });
154
146
  }
@@ -26,6 +26,10 @@ export declare function createDiscussionTool(executor: GhExecutor): {
26
26
  type: string;
27
27
  description: string;
28
28
  };
29
+ body_file: {
30
+ type: string;
31
+ description: string;
32
+ };
29
33
  category: {
30
34
  type: string;
31
35
  description: string;
@@ -1,4 +1,6 @@
1
+ import { wrapWithGate } from '../destructive.js';
1
2
  import { renderDiscussionMarkdown } from '../renderers/markdown.js';
3
+ import { resolveBodySource } from './body-input.js';
2
4
  function normalizeOutput(data) {
3
5
  if (data && typeof data === 'object' && !Array.isArray(data)) {
4
6
  return data;
@@ -11,7 +13,7 @@ function normalizeOutput(data) {
11
13
  const DISCUSSION_LIST_FIELDS = 'number,title,body,category,author,closed,createdAt,updatedAt,url,labels,answered,stateReason';
12
14
  const DISCUSSION_VIEW_FIELDS = 'number,title,body,category,author,state,createdAt,updatedAt,url,labels,answered,comments';
13
15
  export function createDiscussionTool(executor) {
14
- return {
16
+ return wrapWithGate({
15
17
  name: 'github_discussion',
16
18
  description: '管理 GitHub Discussions 讨论区:浏览社区讨论、检视讨论详情与回复楼层、发起新讨论议题、编辑现有讨论以及追加评论回复。基于 gh CLI preview 特性。',
17
19
  parameters: {
@@ -36,7 +38,11 @@ export function createDiscussionTool(executor) {
36
38
  },
37
39
  body: {
38
40
  type: 'string',
39
- description: '讨论正文或评论正文(create, comment 必需,edit 动作修改正文时可选)。',
41
+ description: '讨论正文或评论正文(create, comment 必需,edit 动作修改正文时可选)。与 body_file 二选一。',
42
+ },
43
+ body_file: {
44
+ type: 'string',
45
+ description: '正文来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 body 二选一;长正文用本参数,无需把全文读进上下文。',
40
46
  },
41
47
  category: {
42
48
  type: 'string',
@@ -74,10 +80,17 @@ export function createDiscussionTool(executor) {
74
80
  },
75
81
  },
76
82
  async execute(args, execContext) {
77
- const cwd = execContext?.cwd || process.cwd();
78
- const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
83
+ const { cwd, sessionId } = executor.resolveExecScope(execContext);
79
84
  const repoArgs = args.repo ? ['-R', args.repo] : [];
80
85
  const runOpts = { cwd, sessionId };
86
+ // 正文来自本地文件时经 stdin 注入(`--body-file -`):绕开 Windows 约 32K 的命令行上限
87
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
88
+ /*
89
+ * 内联正文沿用改造前的 trim 口径(旧代码是 `-b args.body.trim()`):discussion 历史上
90
+ * 就按去空白下发,改成原样会动到既有调用方的 argv。文件来源不 trim——那是用户排好版的
91
+ * Markdown,首尾空行/缩进可能有意义,strip 掉等于替用户改稿。
92
+ */
93
+ const trimInline = (v) => (typeof v === 'string' ? v.trim() : v);
81
94
  switch (args.action) {
82
95
  case 'list': {
83
96
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 100) : 30;
@@ -126,8 +139,10 @@ export function createDiscussionTool(executor) {
126
139
  if (!args.title || typeof args.title !== 'string' || !args.title.trim()) {
127
140
  throw new Error('create 操作必须提供 title(讨论标题)');
128
141
  }
129
- if (!args.body || typeof args.body !== 'string' || !args.body.trim()) {
130
- throw new Error('create 操作必须提供 body(讨论正文)');
142
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
143
+ // 判空只看解析后的权威正文:文件来源时 args.body 恒为空,拿它判定会误杀
144
+ if (body.chars === 0) {
145
+ throw new Error('create 操作必须提供 body 或 body_file(讨论正文)');
131
146
  }
132
147
  if (!args.category || typeof args.category !== 'string' || !args.category.trim()) {
133
148
  throw new Error('create 操作必须提供 category(讨论分类,例如 "General"、"Q&A"、"Ideas")');
@@ -135,11 +150,11 @@ export function createDiscussionTool(executor) {
135
150
  const createCmd = [
136
151
  'discussion', 'create',
137
152
  '-t', args.title.trim(),
138
- '-b', args.body.trim(),
153
+ ...body.argv,
139
154
  '-c', args.category.trim(),
140
155
  ...repoArgs,
141
156
  ];
142
- const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
157
+ const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
143
158
  if (!res.ok)
144
159
  throw new Error(res.error);
145
160
  return {
@@ -151,15 +166,15 @@ export function createDiscussionTool(executor) {
151
166
  if (!args.discussion_number || typeof args.discussion_number !== 'number') {
152
167
  throw new Error('edit 操作必须提供 discussion_number(讨论数字编号)');
153
168
  }
154
- if (!args.title && !args.body) {
169
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
170
+ if (!args.title && body.chars === 0) {
155
171
  throw new Error('edit 操作至少需提供 title 或 body 中的一项进行修改');
156
172
  }
157
173
  const editCmd = ['discussion', 'edit', String(args.discussion_number), ...repoArgs];
158
174
  if (args.title)
159
175
  editCmd.push('-t', String(args.title).trim());
160
- if (args.body)
161
- editCmd.push('-b', String(args.body).trim());
162
- const res = await executor.run(editCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
176
+ editCmd.push(...body.argv);
177
+ const res = await executor.run(editCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
163
178
  if (!res.ok)
164
179
  throw new Error(res.error);
165
180
  return {
@@ -171,15 +186,16 @@ export function createDiscussionTool(executor) {
171
186
  if (!args.discussion_number || typeof args.discussion_number !== 'number') {
172
187
  throw new Error('comment 操作必须提供 discussion_number(讨论数字编号)');
173
188
  }
174
- if (!args.body || typeof args.body !== 'string' || !args.body.trim()) {
175
- throw new Error('comment 操作必须提供 body(评论正文)');
189
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
190
+ if (body.chars === 0) {
191
+ throw new Error('comment 操作必须提供 body 或 body_file(评论正文)');
176
192
  }
177
193
  const commentCmd = [
178
194
  'discussion', 'comment', String(args.discussion_number),
179
- '-b', args.body.trim(),
195
+ ...body.argv,
180
196
  ...repoArgs,
181
197
  ];
182
- const res = await executor.run(commentCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
198
+ const res = await executor.run(commentCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
183
199
  if (!res.ok)
184
200
  throw new Error(res.error);
185
201
  return {
@@ -191,5 +207,5 @@ export function createDiscussionTool(executor) {
191
207
  throw new Error(`未知的 Discussion 操作: ${args.action}`);
192
208
  }
193
209
  },
194
- };
210
+ });
195
211
  }
@@ -71,14 +71,9 @@ export declare function createGistTool(executor: GhExecutor): {
71
71
  public?: undefined;
72
72
  description?: undefined;
73
73
  success?: undefined;
74
+ gist_id?: undefined;
74
75
  message?: undefined;
75
76
  detail?: undefined;
76
- status?: undefined;
77
- action?: undefined;
78
- gist_id?: undefined;
79
- warning?: undefined;
80
- impact?: undefined;
81
- requiredAction?: undefined;
82
77
  } | {
83
78
  items?: undefined;
84
79
  count?: undefined;
@@ -92,11 +87,6 @@ export declare function createGistTool(executor: GhExecutor): {
92
87
  success?: undefined;
93
88
  message?: undefined;
94
89
  detail?: undefined;
95
- status?: undefined;
96
- action?: undefined;
97
- warning?: undefined;
98
- impact?: undefined;
99
- requiredAction?: undefined;
100
90
  } | {
101
91
  items?: undefined;
102
92
  count?: undefined;
@@ -110,11 +100,6 @@ export declare function createGistTool(executor: GhExecutor): {
110
100
  description: any;
111
101
  message: string;
112
102
  detail?: undefined;
113
- status?: undefined;
114
- action?: undefined;
115
- warning?: undefined;
116
- impact?: undefined;
117
- requiredAction?: undefined;
118
103
  } | {
119
104
  items?: undefined;
120
105
  count?: undefined;
@@ -128,28 +113,5 @@ export declare function createGistTool(executor: GhExecutor): {
128
113
  gist_id: string;
129
114
  message: string;
130
115
  detail: string;
131
- status?: undefined;
132
- action?: undefined;
133
- warning?: undefined;
134
- impact?: undefined;
135
- requiredAction?: undefined;
136
- } | {
137
- items?: undefined;
138
- count?: undefined;
139
- rawText?: undefined;
140
- filename?: undefined;
141
- content?: undefined;
142
- url?: undefined;
143
- public?: undefined;
144
- description?: undefined;
145
- success?: undefined;
146
- message?: undefined;
147
- detail?: undefined;
148
- status: string;
149
- action: string;
150
- gist_id: string;
151
- warning: string;
152
- impact: string;
153
- requiredAction: string;
154
116
  }>;
155
117
  };
package/lib/tools/gist.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { wrapWithGate } from '../destructive.js';
1
2
  import { renderGistMarkdown, parseGistListOutput } from '../renderers/markdown.js';
2
3
  export function createGistTool(executor) {
3
- return {
4
+ return wrapWithGate({
4
5
  name: 'github_gist',
5
6
  description: '管理 GitHub Gist 代码片段与诊断留痕:查看 Gist 列表、检视代码正文 (view)、通过文本内容或本地文件创建新 Gist (create,默认安全 secret 模式)、编辑以及安全删除。删除操作不可逆,需 confirm: true。',
6
7
  parameters: {
@@ -58,8 +59,7 @@ export function createGistTool(executor) {
58
59
  },
59
60
  },
60
61
  async execute(args, execContext) {
61
- const cwd = execContext?.cwd || process.cwd();
62
- const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
62
+ const { cwd, sessionId } = executor.resolveExecScope(execContext);
63
63
  const runOpts = { cwd, sessionId, rawText: true };
64
64
  switch (args.action) {
65
65
  case 'list': {
@@ -161,16 +161,6 @@ export function createGistTool(executor) {
161
161
  if (!args.gist_id) {
162
162
  throw new Error('删除 Gist 时 `gist_id` 参数为必填项。');
163
163
  }
164
- if (args.confirm !== true) {
165
- return {
166
- status: 'AWAITING_CONFIRMATION',
167
- action: 'delete',
168
- gist_id: String(args.gist_id),
169
- warning: `⚠️ 高危破坏性操作:即将永久删除 Gist 代码片段 "${args.gist_id}"`,
170
- impact: `删除 Gist 为物理删除,无法通过 GitHub API 恢复,所有引用该 Gist URL 的外部链接将失效并返回 404。`,
171
- requiredAction: `如确认彻底删除,请在参数中附加 \`confirm: true\` 重新调用。示例:\`github_gist(action: 'delete', gist_id: '${args.gist_id}', confirm: true)\``,
172
- };
173
- }
174
164
  const delCmd = ['gist', 'delete', String(args.gist_id), '--yes'];
175
165
  const res = await executor.run(delCmd, runOpts);
176
166
  if (!res.ok)
@@ -186,5 +176,5 @@ export function createGistTool(executor) {
186
176
  throw new Error(`不支持的 gist 操作: "${args.action}"。可选值: list, view, create, edit, delete。`);
187
177
  }
188
178
  },
189
- };
179
+ });
190
180
  }