@ai-wendao/ant 0.3.9 → 0.3.11

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/MIGRATION.md CHANGED
@@ -13,7 +13,7 @@
13
13
  此方式适用于 DSH Desktop RC2 使用默认、启用 HMR 的 Desktop profile,且目标 ANT 版本已发布到 npm:
14
14
 
15
15
  1. 打开 **插件 → ANT**,点击 **卸载** 并确认。
16
- 2. 回到插件列表,点击 **添加插件**,输入已发布的直接包名,例如 `@ai-wendao/ant@0.3.9`,然后点击 **安装**。
16
+ 2. 回到插件列表,点击 **添加插件**,输入已发布的直接包名,例如 `@ai-wendao/ant@0.3.11`,然后点击 **安装**。
17
17
  3. 安装完成后启用 ANT,打开设置页,检查服务连接与获准模型。
18
18
 
19
19
  RC2 的 Desktop 基础 profile 默认启用 HMR;官方管理器在该模式下会先卸载 Bundle,再用随 DSH 提供的包管理器移除依赖。包卸载本身不会调用 ANT 的“断开设备”操作,也不会清空 DSH 凭证存储。若 profile 禁用了 HMR,管理器会提示先停止 profile;此时改用下方 CLI 备用流程。真实成员 profile 的凭证保留仍需在目标 RC2 客户端上验收。
@@ -59,14 +59,14 @@ node packages/ant-bundle/tools/migrate-legacy.mjs migrate --apply
59
59
  安装了系统 Node.js 的成员也可以从已发布 npm 包运行:
60
60
 
61
61
  ```sh
62
- npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate inspect
63
- npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate
62
+ npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate inspect
63
+ npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate migrate
64
64
  ```
65
65
 
66
66
  退出 DSH 后执行变更:
67
67
 
68
68
  ```sh
69
- npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate --apply
69
+ npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate migrate --apply
70
70
  ```
71
71
 
72
72
  若 CLI 不在 PATH,可追加 `--dsh-cli "<Desktop CLI 完整路径>"`;若 DSH 使用自定义数据目录,可追加 `--dsh-home "<DSH_HOME 路径>"`。
@@ -76,8 +76,8 @@ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate --apply
76
76
  `migrate --apply` 会输出受限权限的备份目录,备份包含 profile 的 `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`(若存在)及其 SHA-256 校验值,不包含密钥库。备份仅用于迁移状态核验和保留原始清单,不等于离线恢复包。确认 DSH 已退出后,通过同一工具调用:
77
77
 
78
78
  ```sh
79
- npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate rollback --backup "<备份目录>"
80
- npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate rollback --backup "<备份目录>" --apply
79
+ npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate rollback --backup "<备份目录>"
80
+ npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate rollback --backup "<备份目录>" --apply
81
81
  ```
82
82
 
83
83
  回退只在 scoped 包已安装、旧 alias 尚未移除的中间态工作;它会卸载 scoped 包并保留旧 alias。已完成迁移的 profile 不接受该回退,避免自动恢复已知有加载问题的 alias。若安装包时 npm registry 不可用,保持 DSH 关闭,稍后重新运行 `migrate --apply`。
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # ANT for DeepSeek Harness
2
2
 
3
- ANT connects a DeepSeek Harness Desktop profile to its team's approved models and collaboration services.
3
+ ANT (AI-native Teams) connects a DeepSeek Harness Desktop profile to its team's membership and approved models.
4
+
5
+ 飞书和企业微信的机器人连接、消息收发由 DSH-IM 负责;ANT Bundle 不再提供自己的 IM 接入。ANT 继续负责团队成员身份和模型授权。
6
+
7
+ ## 企业精选技能
8
+
9
+ 团队管理员可在 ANT 管理台从 GitHub 目录、ZIP、TAR、TAR.GZ/TGZ 或单份 `SKILL.md` 导入技能,核对来源、文件和依赖后发布。已连接团队的成员在 ANT 插件页面点击“安装到这台 DSH”,由 ANT Host 将固定版本的完整文件集放进 DSH 的用户技能目录;随后由 DSH 原生技能提供者发现和使用。
10
+
11
+ 安装不会自动运行技能脚本或安装 Python、Node 等系统依赖。ANT 只更新或卸载自己管理且本机内容未被修改的技能目录;发现同名个人技能或本机改动时会停止覆盖。
4
12
 
5
13
  ## Existing `ant` alias installations
6
14
 
package/client.js CHANGED
@@ -144,6 +144,78 @@ window.__ModuleLoader__.load({
144
144
  h(ModelSyncFooter, { remote, serviceUrl, onServiceUrlChange: setServiceUrl }))
145
145
  }
146
146
 
147
+ function SkillCenter({ connected }) {
148
+ const [skills, setSkills] = useState([])
149
+ const [loading, setLoading] = useState(false)
150
+ const [workingId, setWorkingId] = useState('')
151
+ const [message, setMessage] = useState('')
152
+ const refresh = async () => {
153
+ if (!connected) return
154
+ setLoading(true)
155
+ try {
156
+ const response = await fetch('/api/ant-teams/skills', { cache: 'no-store' })
157
+ const result = await readLocalJsonResponse(response)
158
+ if (!response.ok) throw new Error(result.message || `读取技能失败(${response.status})。`)
159
+ if (!Array.isArray(result.skills)) throw new Error('企业技能目录格式无效。')
160
+ setSkills(result.skills)
161
+ setMessage(result.skills.length ? '' : '团队暂未发布精选技能。')
162
+ } catch (error) {
163
+ setSkills([])
164
+ setMessage(error instanceof Error ? error.message : '无法读取企业精选技能。')
165
+ }
166
+ finally { setLoading(false) }
167
+ }
168
+ useEffect(() => {
169
+ if (connected) void refresh()
170
+ else { setSkills([]); setMessage('') }
171
+ }, [connected])
172
+ const changeInstall = async (skill, remove = false) => {
173
+ const action = remove ? '卸载' : skill.local_status === 'update-available' ? '更新' : '安装'
174
+ const prompt = remove
175
+ ? `从这台 DSH 卸载 ${skill.title}?已修改的本地文件不会被删除。`
176
+ : `将 ${skill.title} ${action}到这台 DSH?\n\n本机依赖:${skill.requirements}\n\n技能可能让 DSH 读取文件或执行脚本,仍受 DSH 的工具授权控制。ANT 不会自动安装系统依赖。`
177
+ if (!window.confirm(prompt)) return
178
+ setWorkingId(skill.id)
179
+ setMessage(`正在${action} ${skill.title}…`)
180
+ try {
181
+ const response = await fetch(`/api/ant-teams/skills/${remove ? 'uninstall' : 'install'}`, {
182
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify(remove ? { dsh_name: skill.dsh_name } : { skill_id: skill.id }),
184
+ })
185
+ const result = await readLocalJsonResponse(response)
186
+ if (!response.ok) throw new Error(result.message || `${action}失败(${response.status})。`)
187
+ await refresh()
188
+ setMessage(`${skill.title} 已${action}。DSH 将从本机技能目录发现它。`)
189
+ } catch (error) { setMessage(error instanceof Error ? error.message : `${action}失败。`) }
190
+ finally { setWorkingId('') }
191
+ }
192
+ return h('section', { style: panelStyle },
193
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 } },
194
+ h('div', null,
195
+ h('h3', { style: { margin: 0, fontSize: 16, fontWeight: 650 } }, '企业精选技能'),
196
+ h('p', { style: { ...mutedTextStyle, marginTop: 4 } }, '管理员审核后发布;点击安装即可交给 DSH 原生技能系统使用。')),
197
+ h('button', { type: 'button', disabled: !connected || loading || Boolean(workingId), onClick: () => void refresh(), style: secondaryButtonStyle }, loading ? '读取中…' : '刷新')),
198
+ !connected ? h('p', { style: mutedTextStyle }, '连接团队后可浏览和安装精选技能。') : null,
199
+ message ? h('p', { role: 'status', style: mutedTextStyle }, message) : null,
200
+ ...skills.map((skill) => h('div', { key: skill.id, style: { display: 'grid', gap: 7, padding: '12px 0', borderTop: '1px solid var(--dsw-alias-border, #8c99994d)' } },
201
+ h('strong', { style: { fontSize: 14 } }, skill.title),
202
+ h('p', { style: mutedTextStyle }, skill.description),
203
+ h('p', { style: mutedTextStyle }, `来源:${skill.source_kind === 'github' ? 'GitHub' : '管理员上传'} · 许可证:${skill.license} · ${skill.file_count} 个文件`),
204
+ h('p', { style: mutedTextStyle }, `本机依赖:${skill.requirements}`),
205
+ skill.local_status === 'conflict'
206
+ ? h('p', { role: 'status', style: mutedTextStyle }, '本机同名技能有未受 ANT 管理的内容、文件已被修改或备份损坏;请联系管理员处理。')
207
+ : skill.local_status === 'not-discovered'
208
+ ? h('div', null,
209
+ h('p', { role: 'status', style: mutedTextStyle }, '文件已在本机,但 DSH 技能服务没有发现它;请检查 DSH 技能目录配置。'),
210
+ h('button', { type: 'button', disabled: Boolean(workingId), onClick: () => void changeInstall(skill, true), style: secondaryButtonStyle }, '卸载本机副本'))
211
+ : h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
212
+ h('button', { type: 'button', disabled: !connected || Boolean(workingId) || skill.local_status === 'installed',
213
+ onClick: () => void changeInstall(skill), style: primaryButtonStyle },
214
+ skill.local_status === 'installed' ? '已安装' : skill.local_status === 'update-available' ? '更新技能' : '安装到这台 DSH'),
215
+ skill.local_status !== 'not-installed' ? h('button', { type: 'button', disabled: Boolean(workingId),
216
+ onClick: () => void changeInstall(skill, true), style: secondaryButtonStyle }, '卸载') : null))))
217
+ }
218
+
147
219
  function ModelSyncFooter({ remote, serviceUrl, onServiceUrlChange }) {
148
220
  const [email, setEmail] = useState('')
149
221
  const [code, setCode] = useState('')
@@ -153,14 +225,6 @@ window.__ModuleLoader__.load({
153
225
  const [credentialConfigured, setCredentialConfigured] = useState(null)
154
226
  const [reauthenticate, setReauthenticate] = useState(false)
155
227
  const [needsReconnect, setNeedsReconnect] = useState(false)
156
- const [feishuCode, setFeishuCode] = useState('')
157
- const [feishuCodeMessage, setFeishuCodeMessage] = useState('')
158
- const [feishuApps, setFeishuApps] = useState([])
159
- const [feishuAppId, setFeishuAppId] = useState('')
160
- const [feishuAppSecret, setFeishuAppSecret] = useState('')
161
- const [feishuVerificationToken, setFeishuVerificationToken] = useState('')
162
- const [feishuEncryptKey, setFeishuEncryptKey] = useState('')
163
- const [feishuAppStatus, setFeishuAppStatus] = useState('')
164
228
  const [bundleRelease, setBundleRelease] = useState(null)
165
229
  const [bundleUpdate, setBundleUpdate] = useState({ state: 'idle', currentVersion: '', message: '' })
166
230
  const [approvedBuilds, setApprovedBuilds] = useState([])
@@ -439,7 +503,6 @@ window.__ModuleLoader__.load({
439
503
  if (configured) {
440
504
  void refreshModels()
441
505
  void checkBundleUpdate()
442
- void refreshFeishuApps().catch((error) => setFeishuAppStatus(error.message))
443
506
  }
444
507
  }).catch(() => { if (current) setCredentialConfigured(null) })
445
508
  return () => { current = false }
@@ -524,7 +587,6 @@ window.__ModuleLoader__.load({
524
587
  setCredentialConfigured(true)
525
588
  setNeedsReconnect(false)
526
589
  setReauthenticate(false)
527
- await refreshFeishuApps().catch((error) => setFeishuAppStatus(error.message))
528
590
  setMessage(`已连接 ${result.email || normalizedEmail},同步 ${result.models.length} 个获准模型。它们现已出现在 DSH 模型选择器中。`)
529
591
  } catch (error) {
530
592
  setMessage(error instanceof Error ? error.message : '连接失败,请稍后重试。')
@@ -533,82 +595,6 @@ window.__ModuleLoader__.load({
533
595
  }
534
596
  }
535
597
 
536
- const manageFeishuApps = async (body) => {
537
- const response = await fetch('/api/ant-teams/feishu/apps', {
538
- method: 'POST',
539
- headers: { 'Content-Type': 'application/json' },
540
- body: JSON.stringify(body),
541
- })
542
- const result = await readLocalJsonResponse(response)
543
- if (!response.ok) throw new Error(result.message || `飞书操作失败(${response.status})`)
544
- return result
545
- }
546
-
547
- const refreshFeishuApps = async () => {
548
- const result = await manageFeishuApps({ action: 'list' })
549
- if (!Array.isArray(result.apps)) throw new Error('ANT 返回的飞书应用列表格式无效。')
550
- setFeishuApps(result.apps)
551
- }
552
-
553
- const saveFeishuApp = async (event) => {
554
- event.preventDefault()
555
- setBusy(true)
556
- setFeishuAppStatus('正在验证飞书应用并保存密钥…')
557
- setFeishuCode('')
558
- setFeishuCodeMessage('')
559
- try {
560
- const result = await manageFeishuApps({
561
- action: 'save',
562
- app_id: feishuAppId.trim(),
563
- app_secret: feishuAppSecret,
564
- verification_token: feishuVerificationToken,
565
- encrypt_key: feishuEncryptKey,
566
- })
567
- if (!result.app?.id || !result.app?.callback_url) throw new Error('ANT 未返回飞书回调信息。')
568
- setFeishuAppId('')
569
- setFeishuAppSecret('')
570
- setFeishuVerificationToken('')
571
- setFeishuEncryptKey('')
572
- setFeishuApps((apps) => [...apps.filter((app) => app.id !== result.app.id), result.app])
573
- setFeishuAppStatus('App ID 和 App Secret 已验证;回调密钥已加密保存。请把下方回调地址填入该飞书应用的事件订阅设置。')
574
- } catch (error) {
575
- setFeishuAppStatus(error instanceof Error ? error.message : '飞书应用保存失败,请稍后重试。')
576
- } finally {
577
- setBusy(false)
578
- }
579
- }
580
-
581
- const createFeishuLinkCode = async (app) => {
582
- setBusy(true)
583
- setFeishuCode('')
584
- setFeishuCodeMessage('正在生成一次性飞书绑定码…')
585
- try {
586
- const result = await manageFeishuApps({ action: 'link-code', id: app.id })
587
- if (typeof result.code !== 'string') throw new Error(result.message || 'ANT 未返回绑定码。')
588
- setFeishuCode(result.code)
589
- setFeishuCodeMessage(`有效期 ${Math.ceil(result.expires_in_seconds / 60)} 分钟。请在飞书中私聊该机器人,发送:绑定 ${result.code}`)
590
- } catch (error) {
591
- setFeishuCodeMessage(error instanceof Error ? error.message : '飞书绑定码生成失败,请稍后重试。')
592
- } finally {
593
- setBusy(false)
594
- }
595
- }
596
-
597
- const disableFeishuApp = async (app) => {
598
- if (!window.confirm(`停用飞书应用 ${app.app_id}?停用后它不能再触发新任务,已接收的任务仍会尝试完成并回复。`)) return
599
- setBusy(true)
600
- setFeishuAppStatus('正在停用飞书应用…')
601
- try {
602
- await manageFeishuApps({ action: 'disable', id: app.id })
603
- await refreshFeishuApps()
604
- setFeishuAppStatus('飞书应用已停用,并解除其成员身份绑定。')
605
- } catch (error) {
606
- setFeishuAppStatus(error instanceof Error ? error.message : '飞书应用停用失败。')
607
- } finally {
608
- setBusy(false)
609
- }
610
- }
611
-
612
598
  const showConnectForm = credentialConfigured !== true || reauthenticate || needsReconnect
613
599
  return h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 400px), 1fr))', alignItems: 'start', gap: 16 } },
614
600
  h('section', { style: panelStyle },
@@ -669,43 +655,8 @@ window.__ModuleLoader__.load({
669
655
  }),
670
656
  h('button', { type: 'submit', disabled: busy || code.length !== 8, style: primaryButtonStyle }, busy ? '连接中…' : '验证并连接')) : null,
671
657
  message ? h('p', { role: 'status', style: mutedTextStyle }, message) : null),
672
- credentialConfigured === true && !needsReconnect && !reauthenticate ? h('section', { style: panelStyle },
673
- h('div', null,
674
- h('h3', { style: { margin: 0, fontSize: 16, fontWeight: 650 } }, '个人飞书应用'),
675
- h('p', { style: { ...mutedTextStyle, marginTop: 4 } }, '每位成员自行管理飞书应用和身份绑定。群 ID 不参与授权;只有绑定到此应用的本人可以触发自己的 DSH。')),
676
- h('form', { onSubmit: saveFeishuApp, style: { display: 'grid', gap: 9 } },
677
- h('label', { htmlFor: 'ant-feishu-app-id', style: { fontSize: 13, fontWeight: 550 } }, '飞书 App ID'),
678
- h('input', { id: 'ant-feishu-app-id', value: feishuAppId, required: true, maxLength: 128, placeholder: 'cli_…', onChange: (event) => setFeishuAppId(event.target.value), style: inputStyle }),
679
- h('p', { style: mutedTextStyle }, '在飞书开放平台的应用凭证页查看,通常以 cli_ 开头。'),
680
- h('label', { htmlFor: 'ant-feishu-app-secret', style: { fontSize: 13, fontWeight: 550 } }, 'App Secret'),
681
- h('input', { id: 'ant-feishu-app-secret', type: 'password', autoComplete: 'new-password', value: feishuAppSecret, required: true, maxLength: 512, onChange: (event) => setFeishuAppSecret(event.target.value), style: inputStyle }),
682
- h('p', { style: mutedTextStyle }, '同一应用凭证页中的密钥;ANT 用它验证 App ID 并读取机器人身份。'),
683
- h('label', { htmlFor: 'ant-feishu-verification-token', style: { fontSize: 13, fontWeight: 550 } }, 'Verification Token'),
684
- h('input', { id: 'ant-feishu-verification-token', type: 'password', autoComplete: 'new-password', value: feishuVerificationToken, required: true, maxLength: 512, onChange: (event) => setFeishuVerificationToken(event.target.value), style: inputStyle }),
685
- h('p', { style: mutedTextStyle }, '在事件订阅设置中生成,用来校验飞书回调是否属于此应用。'),
686
- h('label', { htmlFor: 'ant-feishu-encrypt-key', style: { fontSize: 13, fontWeight: 550 } }, 'Encrypt Key'),
687
- h('input', { id: 'ant-feishu-encrypt-key', type: 'password', autoComplete: 'new-password', value: feishuEncryptKey, required: true, maxLength: 512, onChange: (event) => setFeishuEncryptKey(event.target.value), style: inputStyle }),
688
- h('p', { style: mutedTextStyle }, '在事件订阅设置中生成;启用事件加密时,ANT 用它校验签名并解密回调。'),
689
- h('p', { style: mutedTextStyle }, '保存时 ANT 会验证 App ID 和 App Secret,并读取该应用机器人的身份。所有密钥只在服务端加密保存。'),
690
- h('button', { type: 'submit', disabled: busy, style: primaryButtonStyle }, busy ? '正在验证并保存…' : '验证并保存应用')),
691
- feishuAppStatus ? h('p', { role: 'status', style: mutedTextStyle }, feishuAppStatus) : null,
692
- feishuApps.length ? h('div', { style: { display: 'grid', gap: 10 } }, ...feishuApps.map((app) => h('article', { key: app.id, style: { ...panelStyle, padding: 14, gap: 8 } },
693
- h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } },
694
- h('strong', null, app.app_id),
695
- h('span', { style: mutedTextStyle }, !app.active ? '已停用' : app.linked ? '已绑定飞书身份' : '尚未绑定')),
696
- h('p', { style: mutedTextStyle }, '飞书事件回调地址:'),
697
- h('code', { style: { overflowWrap: 'anywhere', padding: 8, borderRadius: 6, backgroundColor: 'var(--dsw-alias-bg-layer-2)' } }, app.callback_url),
698
- app.active ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
699
- h('button', { type: 'button', disabled: busy, onClick: () => void createFeishuLinkCode(app), style: primaryButtonStyle }, '生成此机器人的绑定码'),
700
- h('button', { type: 'button', disabled: busy, onClick: () => void disableFeishuApp(app), style: secondaryButtonStyle }, '停用应用'))
701
- : h('button', { type: 'button', disabled: busy, onClick: () => { setFeishuAppId(app.app_id); setFeishuAppStatus('请重新填写此应用的三个密钥以启用。') }, style: secondaryButtonStyle }, '重新配置启用')))) : null,
702
- feishuCode ? h('code', { style: {
703
- display: 'block', padding: '12px 14px', borderRadius: 9,
704
- color: 'var(--dsw-alias-label-primary)',
705
- backgroundColor: 'var(--dsw-alias-bg-layer-2)', fontSize: 18, letterSpacing: '.12em',
706
- } }, feishuCode) : null,
707
- feishuCodeMessage ? h('p', { role: 'status', style: mutedTextStyle }, feishuCodeMessage) : null,
708
- h('p', { style: mutedTextStyle }, '保存后,把对应应用显示的回调地址填入飞书事件订阅,订阅 im.message.receive_v1。私聊绑定和机器人收发消息需要 im:message;群内 @ 机器人触发还需要 im:message.group_at_msg。验证回调后生成绑定码,再在飞书中私聊该机器人发送“绑定 <验证码>”。之后可在任意群中 @ 该机器人;ANT 会核对机器人和发送者身份,再把任务交给你的 DSH。')) : null)
658
+ h(SkillCenter, { connected: credentialConfigured === true }))
659
+
709
660
  }
710
661
 
711
662
  function apply(ctx) {
package/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export const name = 'ant'
2
2
 
3
+ import { installSkill, listSkills, uninstallSkill } from './skill-install.js'
4
+
3
5
  export const inject = []
4
6
 
5
7
  const MODEL_SYNC_PATH = '/api/ant-teams/models'
@@ -8,20 +10,14 @@ const BUNDLE_RELEASE_PATH = '/api/ant-teams/bundle-release'
8
10
  const DISCONNECT_PATH = '/api/ant-teams/disconnect'
9
11
  const REQUEST_CODE_PATH = '/api/ant-teams/request-code'
10
12
  const CONNECT_PATH = '/api/ant-teams/connect'
11
- const FEISHU_LINK_CODE_PATH = '/api/ant-teams/feishu/link-code'
12
- const FEISHU_MEMBER_APPS_PATH = '/api/ant-teams/feishu/apps'
13
- const FEISHU_CLAIM_PATH = '/api/ant-teams/feishu/tasks/claim'
14
- const FEISHU_HEARTBEAT_PATH = '/api/ant-teams/feishu/tasks/heartbeat'
15
- const FEISHU_COMPLETE_PATH = '/api/ant-teams/feishu/tasks/complete'
16
- const FEISHU_FINISH_PATH = '/api/ant-teams/feishu/tasks/finish'
13
+ const SKILLS_PATH = '/api/ant-teams/skills'
14
+ const SKILL_INSTALL_PATH = '/api/ant-teams/skills/install'
15
+ const SKILL_UNINSTALL_PATH = '/api/ant-teams/skills/uninstall'
17
16
  const ANT_NPM_PACKAGE_NAME = '@ai-wendao/ant'
18
17
  const MAX_MODEL_RESPONSE_BYTES = 1024 * 1024
19
18
  const MODEL_TOKEN_REF = 'ANT_MODEL_API_KEY'
20
19
  const SERVICE_RECORD_KEY = 'ant/team-service'
21
20
  const SESSION_HEARTBEAT_INTERVAL_MS = 30_000
22
- const FEISHU_POLL_INTERVAL_MS = 5_000
23
- const FEISHU_TASK_HEARTBEAT_INTERVAL_MS = 30_000
24
- const FEISHU_TASK_MAX_RUNTIME_MS = 30 * 60_000
25
21
 
26
22
  function jsonResponse(status, value) {
27
23
  return new Response(JSON.stringify(value), {
@@ -309,231 +305,53 @@ async function disconnectMember(request, credentials, settings) {
309
305
  }
310
306
  }
311
307
 
312
- async function relayFeishuRequest(request, path, credentials, settings) {
313
- if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
314
- let connection
315
- try { connection = await readMemberConnection(credentials, settings) } catch {
316
- return jsonResponse(500, { message: 'DSH 无法读取 ANT 连接配置。' })
317
- }
318
- if (!connection) return jsonResponse(401, { message: '请先在 ANT 插件中连接团队。' })
319
- try {
320
- const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
321
- if (session.status === 401 || session.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
322
- if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
323
- const body = await request.text()
324
- if (body.length > 64 * 1024) return jsonResponse(413, { message: '飞书任务请求过大。' })
325
- const upstream = await fetch(new URL(path, connection.serviceUrl), {
326
- method: 'POST',
327
- headers: {
328
- authorization: `Bearer ${connection.token}`,
329
- ...(body ? { 'content-type': 'application/json' } : {}),
330
- accept: 'application/json',
331
- },
332
- ...(body ? { body } : {}),
333
- redirect: 'manual',
334
- signal: AbortSignal.timeout(15_000),
335
- })
336
- const text = await readLimitedBody(upstream)
337
- if (text === null) return jsonResponse(502, { message: 'ANT 飞书任务响应过大。' })
338
- return new Response(text, {
339
- status: upstream.status,
340
- headers: { 'content-type': upstream.headers.get('content-type') || 'application/json', 'cache-control': 'no-store' },
341
- })
342
- } catch {
343
- return jsonResponse(502, { message: '无法连接 ANT 飞书任务服务。' })
344
- }
308
+ async function memberSkillConnection(credentials, settings) {
309
+ const connection = await readMemberConnection(credentials, settings)
310
+ if (!connection) return null
311
+ const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
312
+ if (!session.ok) return null
313
+ return connection
345
314
  }
346
315
 
347
- async function requestFeishuJson(serviceUrl, token, path, body, timeout = 15_000) {
348
- const response = await fetch(new URL(path, serviceUrl), {
349
- method: 'POST',
350
- headers: {
351
- authorization: `Bearer ${token}`,
352
- accept: 'application/json',
353
- ...(body === undefined ? {} : { 'content-type': 'application/json' }),
354
- },
355
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
356
- redirect: 'manual',
357
- signal: AbortSignal.timeout(timeout),
358
- })
359
- const text = await readLimitedBody(response)
360
- if (text === null) throw new Error('ANT 飞书任务响应过大')
361
- if (!response.ok) throw new Error(`ANT 飞书任务请求失败(${response.status})`)
362
- if (response.status === 204 || text === '') return null
363
- try { return JSON.parse(text) } catch { throw new Error('ANT 飞书任务响应格式无效') }
364
- }
365
-
366
- async function syncModelsToHost(settings, serviceUrl, models) {
367
- if (typeof settings?.mutate !== 'function') throw new Error('当前 DSH 版本未提供模型设置接口。')
368
- const grouped = {
369
- openai: models.filter((model) => model.protocol === 'openai'),
370
- anthropic: models.filter((model) => model.protocol === 'anthropic'),
371
- }
372
- const ops = []
373
- for (const protocol of ['openai', 'anthropic']) {
374
- const route = protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
375
- const protocolModels = grouped[protocol]
376
- if (protocolModels.length === 0) {
377
- ops.push({ op: 'unset', path: ['providers', route] })
378
- continue
379
- }
380
- ops.push({ op: 'set', path: ['providers', route], value: {
381
- displayName: protocol === 'openai' ? 'Lakala (OpenAI)' : 'Lakala (Anthropic)',
382
- apiKeyEnv: 'ANT_MODEL_API_KEY',
383
- api: protocol === 'openai' ? 'openai-completions' : 'anthropic-messages',
384
- baseURL: protocol === 'openai'
385
- ? new URL('/model-gateway/openai/v1', serviceUrl).toString()
386
- : new URL('/model-gateway/anthropic', serviceUrl).toString(),
387
- models: protocolModels.map((model) => ({ id: model.model_id, name: model.display_name })),
388
- } })
316
+ async function fetchMemberSkills(request, credentials, settings, registry) {
317
+ if (request.method !== 'GET') return jsonResponse(405, { message: '只支持 GET 请求' })
318
+ try {
319
+ const connection = await memberSkillConnection(credentials, settings)
320
+ if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队,或重新验证工作邮箱。' })
321
+ return jsonResponse(200, { skills: await listSkills(connection, registry) })
322
+ } catch (error) {
323
+ return jsonResponse(502, { message: error instanceof Error ? error.message : '无法读取企业精选技能。' })
389
324
  }
390
- const result = await settings.mutate('llm-pi-ai', ops, undefined)
391
- if (!result.ok) throw new Error('DSH 无法更新 ANT 获准模型。')
392
- }
393
-
394
- function assistantMessageText(event) {
395
- const content = event?.data?.message?.content
396
- if (!Array.isArray(content)) return ''
397
- return content.filter((part) => part?.type === 'text' && typeof part.text === 'string')
398
- .map((part) => part.text).join('')
399
325
  }
400
326
 
401
- async function executeFeishuTask(task, connection, settings, sessionController, stoppedSignal) {
402
- let sessionId
403
- let promptAccepted = false
404
- let followAbort
405
- let taskHeartbeat
406
- let runtimeTimeout
407
- let timedOut = false
408
- let stopSignalListener
327
+ async function installMemberSkill(request, credentials, settings, registry) {
328
+ if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
329
+ let body
409
330
  try {
410
- const catalogResponse = await fetch(new URL('/api/me/models', connection.serviceUrl), {
411
- headers: { authorization: `Bearer ${connection.token}`, accept: 'application/json' },
412
- redirect: 'manual',
413
- signal: AbortSignal.timeout(10_000),
414
- })
415
- const text = await readLimitedBody(catalogResponse)
416
- if (!catalogResponse.ok || text === null) throw new Error('ANT 当前无法返回获准模型。')
417
- const catalog = JSON.parse(text)
418
- if (normalizeServiceUrl(catalog.service_url) !== connection.serviceUrl || !Array.isArray(catalog.models)) {
419
- throw new Error('ANT 返回的模型目录无效。')
420
- }
421
- await syncModelsToHost(settings, connection.serviceUrl, catalog.models)
422
- const model = task.models?.[0]
423
- if (!model || !catalog.models.some((item) => item.protocol === model.protocol && item.model_id === model.model_id)) {
424
- throw new Error('任务模型已不在成员授权目录中。')
425
- }
426
- if (typeof sessionController?.create !== 'function' || typeof sessionController?.follow !== 'function'
427
- || typeof sessionController?.selectModel !== 'function' || typeof sessionController?.prompt !== 'function') {
428
- throw new Error('当前 DSH 版本未提供 Session Controller 接口。')
429
- }
430
- sessionId = await sessionController.create(task.session_id ? { sessionId: task.session_id } : {})
431
- const provider = model.protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
432
- await sessionController.selectModel({ sessionId, provider, model: model.model_id })
433
- followAbort = new AbortController()
434
- stopSignalListener = () => followAbort.abort()
435
- stoppedSignal.addEventListener('abort', stopSignalListener, { once: true })
436
- const follow = sessionController.follow({ address: { kind: 'session', sessionId } }, followAbort.signal)
437
- const iterator = follow[Symbol.asyncIterator]()
438
- const opening = await iterator.next()
439
- if (opening.done || opening.value?.type !== 'snapshot') throw new Error('DSH 会话无法开始跟随。')
440
- if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
441
-
442
- await sessionController.prompt({
443
- requestId: globalThis.crypto.randomUUID(),
444
- sessionId,
445
- mode: 'queue',
446
- content: [{ type: 'text', text: task.prompt }],
447
- }, followAbort.signal)
448
- promptAccepted = true
449
- taskHeartbeat = setInterval(() => {
450
- void updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
451
- .then((response) => {
452
- if (!response.ok) throw new Error('ANT model session expired')
453
- return requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/heartbeat', { task_id: task.id })
454
- })
455
- .catch(() => followAbort.abort())
456
- }, FEISHU_TASK_HEARTBEAT_INTERVAL_MS)
457
- taskHeartbeat.unref?.()
458
- runtimeTimeout = setTimeout(() => { timedOut = true; followAbort.abort() }, FEISHU_TASK_MAX_RUNTIME_MS)
459
- runtimeTimeout.unref?.()
460
-
461
- let result = ''
462
- while (!stoppedSignal.aborted) {
463
- const next = await iterator.next()
464
- if (next.done) throw new Error('DSH 会话结果流意外结束。')
465
- const frame = next.value
466
- if (frame?.type !== 'event') continue
467
- const event = frame.event
468
- if (event?.type === 'assistant/message') {
469
- const message = assistantMessageText(event)
470
- if (message) result = message
471
- }
472
- if (event?.type === 'turn/end') {
473
- if (event.data?.reason?.kind !== 'completed' || !result.trim()) throw new Error('DSH 任务未能正常完成。')
474
- await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/complete', {
475
- task_id: task.id,
476
- session_id: sessionId,
477
- result,
478
- })
479
- return
480
- }
481
- }
482
- if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
483
- } catch {
484
- const status = promptAccepted ? 'unknown' : 'failed'
485
- try {
486
- await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/finish', {
487
- task_id: task.id,
488
- status: timedOut && promptAccepted ? 'unknown' : status,
489
- })
490
- } catch {}
491
- } finally {
492
- clearInterval(taskHeartbeat)
493
- clearTimeout(runtimeTimeout)
494
- if (stopSignalListener) stoppedSignal.removeEventListener('abort', stopSignalListener)
495
- followAbort?.abort()
331
+ const text = await request.text()
332
+ if (text.length > 4096) throw new Error()
333
+ body = JSON.parse(text)
334
+ } catch { return jsonResponse(400, { message: '技能安装请求无效。' }) }
335
+ try {
336
+ const connection = await memberSkillConnection(credentials, settings)
337
+ if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队,或重新验证工作邮箱。' })
338
+ return jsonResponse(200, await installSkill(connection, body?.skill_id, registry))
339
+ } catch (error) {
340
+ return jsonResponse(409, { message: error instanceof Error ? error.message : '技能安装失败。' })
496
341
  }
497
342
  }
498
343
 
499
- function installFeishuTaskWorker(credentials, settings, sessionController) {
500
- let stopped = false
501
- let busy = false
502
- let taskAbort
503
- let work = Promise.resolve()
504
- let pollWork = Promise.resolve()
505
- const poll = async () => {
506
- if (stopped || busy) return
507
- busy = true
508
- try {
509
- const connection = await readMemberConnection(credentials, settings)
510
- if (!connection) return
511
- const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
512
- if (!session.ok || stopped) return
513
- const claim = await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/claim')
514
- if (!claim?.task || stopped) return
515
- taskAbort = new AbortController()
516
- work = executeFeishuTask(claim.task, connection, settings, sessionController, taskAbort.signal)
517
- await work
518
- } catch {
519
- // A transient polling error is retried on the next interval.
520
- } finally {
521
- busy = false
522
- }
523
- }
524
- const schedulePoll = () => {
525
- if (stopped || busy) return
526
- pollWork = poll()
527
- }
528
- const timer = setInterval(schedulePoll, FEISHU_POLL_INTERVAL_MS)
529
- timer.unref?.()
530
- schedulePoll()
531
- return async () => {
532
- stopped = true
533
- clearInterval(timer)
534
- taskAbort?.abort()
535
- await pollWork
536
- await work
344
+ async function uninstallMemberSkill(request) {
345
+ if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
346
+ let body
347
+ try {
348
+ const text = await request.text()
349
+ if (text.length > 4096) throw new Error()
350
+ body = JSON.parse(text)
351
+ } catch { return jsonResponse(400, { message: '技能卸载请求无效。' }) }
352
+ try { return jsonResponse(200, await uninstallSkill(body?.dsh_name)) }
353
+ catch (error) {
354
+ return jsonResponse(409, { message: error instanceof Error ? error.message : '技能卸载失败。' })
537
355
  }
538
356
  }
539
357
 
@@ -593,7 +411,9 @@ async function fetchAuthRequest(request, endpoint, credentials) {
593
411
  }
594
412
 
595
413
  export function apply(ctx) {
596
- ctx.inject(['connection', 'credentials', 'settings', 'sessionController'], (connectionCtx) => {
414
+ let skillRegistry
415
+ ctx.inject(['skills'], (skillCtx) => { skillRegistry = skillCtx.skills })
416
+ ctx.inject(['connection', 'credentials', 'settings'], (connectionCtx) => {
597
417
  connectionCtx.effect(() => connectionCtx.connection.fetch.register({
598
418
  path: SERVICE_INFO_PATH,
599
419
  methods: ['POST'],
@@ -609,8 +429,21 @@ export function apply(ctx) {
609
429
  connectionCtx.effect(() => connectionCtx.connection.fetch.register({
610
430
  path: BUNDLE_RELEASE_PATH,
611
431
  methods: ['GET'],
432
+ requestBody: 'buffered',
612
433
  fetch: (request) => fetchBundleRelease(request, connectionCtx.credentials, connectionCtx.settings),
613
434
  }), 'ant-teams: authenticated Bundle release relay')
435
+ connectionCtx.effect(() => connectionCtx.connection.fetch.register({
436
+ path: SKILLS_PATH, methods: ['GET'], requestBody: 'buffered',
437
+ fetch: (request) => fetchMemberSkills(request, connectionCtx.credentials, connectionCtx.settings, skillRegistry),
438
+ }), 'ant-teams: enterprise skill catalog')
439
+ connectionCtx.effect(() => connectionCtx.connection.fetch.register({
440
+ path: SKILL_INSTALL_PATH, methods: ['POST'], requestBody: 'buffered',
441
+ fetch: (request) => installMemberSkill(request, connectionCtx.credentials, connectionCtx.settings, skillRegistry),
442
+ }), 'ant-teams: install approved skill into DSH')
443
+ connectionCtx.effect(() => connectionCtx.connection.fetch.register({
444
+ path: SKILL_UNINSTALL_PATH, methods: ['POST'], requestBody: 'buffered',
445
+ fetch: (request) => uninstallMemberSkill(request),
446
+ }), 'ant-teams: remove managed DSH skill')
614
447
  connectionCtx.effect(() => connectionCtx.connection.fetch.register({
615
448
  path: DISCONNECT_PATH,
616
449
  methods: ['POST'],
@@ -628,21 +461,6 @@ export function apply(ctx) {
628
461
  requestBody: 'buffered',
629
462
  fetch: (request) => fetchAuthRequest(request, 'dsh-connect', connectionCtx.credentials),
630
463
  }), 'ant-teams: relay DSH account connection')
631
- for (const [path, endpoint] of [
632
- [FEISHU_MEMBER_APPS_PATH, '/api/feishu/member-apps'],
633
- [FEISHU_LINK_CODE_PATH, '/api/feishu/link-code'],
634
- [FEISHU_CLAIM_PATH, '/api/feishu/tasks/claim'],
635
- [FEISHU_HEARTBEAT_PATH, '/api/feishu/tasks/heartbeat'],
636
- [FEISHU_COMPLETE_PATH, '/api/feishu/tasks/complete'],
637
- [FEISHU_FINISH_PATH, '/api/feishu/tasks/finish'],
638
- ]) {
639
- connectionCtx.effect(() => connectionCtx.connection.fetch.register({
640
- path,
641
- methods: ['POST'],
642
- requestBody: 'buffered',
643
- fetch: (request) => relayFeishuRequest(request, endpoint, connectionCtx.credentials, connectionCtx.settings),
644
- }), `ant-teams: relay Feishu ${endpoint.split('/').at(-1)}`)
645
- }
646
464
  connectionCtx.effect(() => {
647
465
  let stopped = false
648
466
  let inFlight = Promise.resolve()
@@ -670,9 +488,5 @@ export function apply(ctx) {
670
488
  } catch {}
671
489
  }
672
490
  }, 'ant-teams: ANT model activity session')
673
- connectionCtx.effect(
674
- () => installFeishuTaskWorker(connectionCtx.credentials, connectionCtx.settings, connectionCtx.sessionController),
675
- 'ant-teams: DSH-owned Feishu task worker',
676
- )
677
491
  })
678
492
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "ANT",
4
- "description": "AI-native team service for DeepSeek Harness"
4
+ "description": "ANT (AI-native Teams) connects a DeepSeek Harness Desktop profile to team identity and approved models."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "ANT",
4
- "description": "面向 DeepSeek Harness 的 AI-native团队服务"
4
+ "description": "ANT(AI-native Teams)连接 DeepSeek Harness Desktop profile,并提供团队成员身份与获准模型管理。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@ai-wendao/ant",
3
- "version": "0.3.9",
4
- "description": "AI-native team service for DeepSeek Harness",
3
+ "version": "0.3.11",
4
+ "description": "ANT (AI-native Teams) connects a DeepSeek Harness Desktop profile to team identity and approved models.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test client.test.js index.test.js tools/migrate-legacy.test.mjs"
7
+ "test": "node --test client.test.js index.test.js skill-install.test.js tools/migrate-legacy.test.mjs"
8
8
  },
9
9
  "bin": {
10
10
  "ant-migrate": "tools/migrate-legacy.mjs"
@@ -16,7 +16,7 @@
16
16
  "./client": "./client.js",
17
17
  "./locale/*.json": "./locale/*.json"
18
18
  },
19
- "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "tools/migrate-legacy.mjs", "MIGRATION.md", "README.md"],
19
+ "files": ["index.js", "client.js", "skill-install.js", "cordis.patch.yml", "locale/*.json", "tools/migrate-legacy.mjs", "MIGRATION.md", "README.md"],
20
20
  "dsh": {
21
21
  "bundle": {
22
22
  "patch": "./cordis.patch.yml"
@@ -0,0 +1,337 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { chmod, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { gunzipSync } from 'node:zlib'
6
+
7
+ const MARKER = '.ant-managed.json'
8
+ const MAX_FILES = 20_000
9
+ const MAX_FILE_BYTES = 8 * 1024 * 1024
10
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024
11
+ const MAX_PACKAGE_BYTES = 80 * 1024 * 1024
12
+ const MAX_JSON_BYTES = 4 * 1024 * 1024
13
+ const activeSkills = new Set()
14
+
15
+ async function assertDiscovered(registry, root, name) {
16
+ if (typeof registry?.list !== 'function') throw new Error('DSH 技能服务不可用,无法确认安装结果。')
17
+ const expected = resolve(root, name, 'SKILL.md')
18
+ for (let attempt = 0; attempt < 6; attempt++) {
19
+ const entries = await registry.list()
20
+ if (Array.isArray(entries) && entries.some((entry) => entry.name === name
21
+ && typeof entry.path === 'string'
22
+ && (process.platform === 'win32' ? resolve(entry.path).toLowerCase() === expected.toLowerCase() : resolve(entry.path) === expected))) return
23
+ if (attempt < 5) await new Promise((done) => setTimeout(done, 300))
24
+ }
25
+ throw new Error('DSH 没有发现此技能;请检查 DSH 技能目录配置。')
26
+ }
27
+
28
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
29
+ const safeName = (name) => typeof name === 'string' && /^ant-[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) && name.length <= 64
30
+ const safePath = (name) => typeof name === 'string' && name.length > 0 && name.length <= 1024
31
+ && !name.includes('\\') && !name.includes('\0') && name !== MARKER
32
+ && name.split('/').every((part) => part && part !== '.' && part !== '..' && part.length <= 255
33
+ && !/[<>:"|?*]/u.test(part) && !/[. ]$/u.test(part)
34
+ && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))
35
+ const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value)
36
+ const skillRoot = () => {
37
+ const configured = process.env.DSH_HOME?.trim()
38
+ const home = !configured ? join(homedir(), '.dsh')
39
+ : configured === '~' ? homedir()
40
+ : configured.startsWith('~/') || configured.startsWith('~\\')
41
+ ? join(homedir(), configured.slice(2)) : configured
42
+ return join(resolve(home), 'skills')
43
+ }
44
+ const validManagedMarker = (marker, name) => marker?.dsh_name === name && validHash(marker.content_sha256)
45
+ && Array.isArray(marker.files) && marker.files.length > 0 && marker.files.length <= MAX_FILES
46
+ && marker.files.every((file) => safePath(file.path) && validHash(file.sha256) && typeof file.executable === 'boolean')
47
+ && new Set(marker.files.map((file) => file.path)).size === marker.files.length
48
+
49
+ async function isPresent(path) {
50
+ try { return await lstat(path) } catch (error) {
51
+ if (error?.code === 'ENOENT') return null
52
+ throw error
53
+ }
54
+ }
55
+
56
+ async function safeRoot() {
57
+ const root = skillRoot()
58
+ const existing = await isPresent(root)
59
+ if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error('DSH 技能目录不是普通目录,无法安全安装。')
60
+ if (!existing) await mkdir(root, { recursive: true, mode: 0o700 })
61
+ const conflicts = await recoverInterruptedUpdates(root)
62
+ return { root, conflicts }
63
+ }
64
+
65
+ async function recoverInterruptedUpdates(root) {
66
+ const conflicts = new Set()
67
+ const stagingRoot = join(dirname(root), '.ant-skill-staging')
68
+ const stat = await isPresent(stagingRoot)
69
+ if (!stat) return conflicts
70
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('ANT 暂存目录不是普通目录。')
71
+ for (const entry of await readdir(stagingRoot, { withFileTypes: true })) {
72
+ const removedName = entry.name.match(/^removed-(ant-[a-z0-9-]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)?.[1]
73
+ if (entry.isDirectory() && !entry.isSymbolicLink() && safeName(removedName)
74
+ && !activeSkills.has(removedName)) {
75
+ await rm(join(stagingRoot, entry.name), { recursive: true }).catch(() => {})
76
+ continue
77
+ }
78
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !entry.name.startsWith('old-ant-')) continue
79
+ const backup = join(stagingRoot, entry.name)
80
+ const recoveredName = entry.name.match(/^old-(ant-[a-z0-9-]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)?.[1]
81
+ if (!safeName(recoveredName)) continue
82
+ const target = join(root, recoveredName)
83
+ if (await isPresent(target)) continue
84
+ const markerStat = await isPresent(join(backup, MARKER))
85
+ if (!markerStat?.isFile() || markerStat.isSymbolicLink() || markerStat.size > MAX_JSON_BYTES) {
86
+ conflicts.add(recoveredName); continue
87
+ }
88
+ let marker
89
+ try { marker = JSON.parse(await readFile(join(backup, MARKER), 'utf8')) }
90
+ catch { conflicts.add(recoveredName); continue }
91
+ if (marker?.dsh_name !== recoveredName || !validManagedMarker(marker, recoveredName)) {
92
+ conflicts.add(recoveredName); continue
93
+ }
94
+ if (activeSkills.has(recoveredName)) continue
95
+ try {
96
+ await verifyManagedTree(stagingRoot, entry.name, marker)
97
+ await rename(backup, target)
98
+ } catch { conflicts.add(marker.dsh_name) }
99
+ }
100
+ return conflicts
101
+ }
102
+
103
+ async function readManaged(root, name) {
104
+ if (!safeName(name)) throw new Error('技能名称无效。')
105
+ const target = join(root, name)
106
+ const stat = await isPresent(target)
107
+ if (!stat) return null
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('同名目录不是 ANT 管理的普通技能目录。')
109
+ const markerStat = await isPresent(join(target, MARKER))
110
+ if (!markerStat || !markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.size > MAX_JSON_BYTES) {
111
+ throw new Error('同名技能已有本地内容;ANT 不会覆盖。')
112
+ }
113
+ let marker
114
+ try { marker = JSON.parse(await readFile(join(target, MARKER), 'utf8')) } catch {
115
+ throw new Error('同名技能的管理记录无效;ANT 不会覆盖。')
116
+ }
117
+ if (!validManagedMarker(marker, name)) {
118
+ throw new Error('同名技能的管理记录无效;ANT 不会覆盖。')
119
+ }
120
+ return marker
121
+ }
122
+
123
+ async function verifyManagedTree(root, name, marker) {
124
+ const target = join(root, name)
125
+ const expected = new Set([MARKER])
126
+ for (const file of marker.files) {
127
+ const parts = file.path.split('/')
128
+ for (let index = 1; index < parts.length; index++) expected.add(parts.slice(0, index).join('/'))
129
+ expected.add(file.path)
130
+ const fullPath = join(target, ...file.path.split('/'))
131
+ const stat = await isPresent(fullPath)
132
+ if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES
133
+ || (process.platform !== 'win32' && Boolean(stat.mode & 0o111) !== file.executable)
134
+ || hash(await readFile(fullPath)) !== file.sha256) {
135
+ throw new Error('技能文件已被本机修改;ANT 不会覆盖或删除。')
136
+ }
137
+ }
138
+ const walk = async (directory, prefix = '') => {
139
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
140
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name
141
+ if (!expected.has(relative) || entry.isSymbolicLink()) throw new Error('技能目录有额外文件或链接;ANT 不会覆盖或删除。')
142
+ if (entry.isDirectory()) await walk(join(directory, entry.name), relative)
143
+ else if (!entry.isFile()) throw new Error('技能目录有无法识别的内容;ANT 不会覆盖或删除。')
144
+ }
145
+ }
146
+ await walk(target)
147
+ }
148
+
149
+ async function boundedBytes(response, maxBytes) {
150
+ if (!response.ok || !response.body) throw new Error(`团队服务无法提供技能文件(HTTP ${response.status})。`)
151
+ const chunks = []
152
+ let total = 0
153
+ for await (const chunk of response.body) {
154
+ total += chunk.length
155
+ if (total > maxBytes) throw new Error('技能文件超过大小上限。')
156
+ chunks.push(chunk)
157
+ }
158
+ return Buffer.concat(chunks, total)
159
+ }
160
+
161
+ async function fetchJson(url, token) {
162
+ const response = await fetch(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
163
+ const bytes = await boundedBytes(response, MAX_JSON_BYTES)
164
+ try { return JSON.parse(bytes.toString('utf8')) } catch { throw new Error('团队技能目录格式无效。') }
165
+ }
166
+
167
+ export async function listSkills(connection, registry) {
168
+ const skills = await fetchJson(new URL('/api/me/skills', connection.serviceUrl), connection.token)
169
+ if (!Array.isArray(skills)) throw new Error('团队技能目录格式无效。')
170
+ const { root, conflicts } = await safeRoot()
171
+ return Promise.all(skills.map(async (skill) => {
172
+ if (!safeName(skill.dsh_name) || !validHash(skill.content_sha256)) throw new Error('团队技能目录含无效条目。')
173
+ let local_status = conflicts.has(skill.dsh_name) ? 'conflict' : 'not-installed'
174
+ if (local_status === 'conflict') return { ...skill, local_status }
175
+ try {
176
+ const marker = await readManaged(root, skill.dsh_name)
177
+ if (marker) {
178
+ await verifyManagedTree(root, skill.dsh_name, marker)
179
+ if (marker.service_url !== connection.serviceUrl) throw new Error('该目录属于另一个 ANT 服务。')
180
+ local_status = marker.content_sha256 === skill.content_sha256 ? 'installed' : 'update-available'
181
+ }
182
+ } catch { local_status = 'conflict' }
183
+ if (local_status === 'installed' || local_status === 'update-available') {
184
+ try { await assertDiscovered(registry, root, skill.dsh_name) }
185
+ catch { local_status = 'not-discovered' }
186
+ }
187
+ return { ...skill, local_status }
188
+ }))
189
+ }
190
+
191
+ function validateManifest(manifest, skillId) {
192
+ if (manifest?.skill_id !== skillId || !safeName(manifest.dsh_name) || !validHash(manifest.content_sha256)
193
+ || !validHash(manifest.package_sha256) || !Number.isInteger(manifest.package_size)
194
+ || manifest.package_size < 1 || manifest.package_size > MAX_PACKAGE_BYTES
195
+ || !Array.isArray(manifest.files) || manifest.files.length < 1 || manifest.files.length > MAX_FILES) {
196
+ throw new Error('团队技能文件清单无效。')
197
+ }
198
+ const seen = new Set()
199
+ let total = 0
200
+ for (const file of manifest.files) {
201
+ if (!safePath(file.path) || !validHash(file.sha256) || !Number.isInteger(file.file_size)
202
+ || file.file_size < 0 || file.file_size > MAX_FILE_BYTES || typeof file.executable !== 'boolean'
203
+ || seen.has(file.path)) throw new Error('团队技能文件清单无效。')
204
+ seen.add(file.path)
205
+ total += file.file_size
206
+ if (total > MAX_TOTAL_BYTES) throw new Error('技能总大小超过上限。')
207
+ }
208
+ if (!seen.has('SKILL.md')) throw new Error('技能目录缺少 SKILL.md。')
209
+ }
210
+
211
+ function contentDigest(files) {
212
+ const digest = createHash('sha256')
213
+ for (const file of [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) {
214
+ const pathBytes = Buffer.from(file.path)
215
+ const length = Buffer.alloc(8)
216
+ length.writeBigUInt64BE(BigInt(pathBytes.length)); digest.update(length); digest.update(pathBytes)
217
+ length.writeBigUInt64BE(BigInt(file.content.length)); digest.update(length)
218
+ digest.update(Buffer.from([Number(file.executable)])); digest.update(file.content)
219
+ }
220
+ return digest.digest('hex')
221
+ }
222
+
223
+ export async function installSkill(connection, skillId, registry) {
224
+ if (typeof skillId !== 'string' || !/^[0-9a-f-]{36}$/iu.test(skillId)) throw new Error('技能编号无效。')
225
+ const manifest = await fetchJson(new URL(`/api/me/skills/${skillId}/files`, connection.serviceUrl), connection.token)
226
+ validateManifest(manifest, skillId)
227
+ const { root, conflicts } = await safeRoot()
228
+ if (conflicts.has(manifest.dsh_name)) throw new Error('本机技能备份已损坏;请联系管理员处理,ANT 不会覆盖。')
229
+ if (activeSkills.has(manifest.dsh_name)) throw new Error('这个技能正在安装或卸载,请稍后重试。')
230
+ activeSkills.add(manifest.dsh_name)
231
+ try { return await installValidatedSkill(connection, skillId, manifest, root, registry) }
232
+ finally { activeSkills.delete(manifest.dsh_name) }
233
+ }
234
+
235
+ async function installValidatedSkill(connection, skillId, manifest, root, registry) {
236
+ const target = join(root, manifest.dsh_name)
237
+ const oldMarker = await readManaged(root, manifest.dsh_name)
238
+ if (oldMarker) {
239
+ if (oldMarker.service_url !== connection.serviceUrl) throw new Error('同名技能属于另一个 ANT 服务,无法覆盖。')
240
+ await verifyManagedTree(root, manifest.dsh_name, oldMarker)
241
+ if (oldMarker.content_sha256 === manifest.content_sha256) {
242
+ await assertDiscovered(registry, root, manifest.dsh_name)
243
+ return { status: 'installed', dsh_name: manifest.dsh_name }
244
+ }
245
+ }
246
+
247
+ const packageUrl = new URL(`/api/me/skills/${skillId}/package`, connection.serviceUrl)
248
+ const packageResponse = await fetch(packageUrl, { headers: { authorization: `Bearer ${connection.token}` }, redirect: 'manual', signal: AbortSignal.timeout(120_000) })
249
+ const packageBytes = await boundedBytes(packageResponse, manifest.package_size)
250
+ if (packageBytes.length !== manifest.package_size || hash(packageBytes) !== manifest.package_sha256) {
251
+ throw new Error('技能包校验失败,未安装。')
252
+ }
253
+ let unpacked
254
+ try { unpacked = gunzipSync(packageBytes, { maxOutputLength: MAX_TOTAL_BYTES }) }
255
+ catch { throw new Error('技能包解压失败或超过大小上限,未安装。') }
256
+ const files = []
257
+ let offset = 0
258
+ for (const file of manifest.files) {
259
+ const content = unpacked.subarray(offset, offset + file.file_size)
260
+ if (content.length !== file.file_size || hash(content) !== file.sha256) throw new Error('技能文件校验失败,未安装。')
261
+ files.push({ ...file, content })
262
+ offset += file.file_size
263
+ }
264
+ if (offset !== unpacked.length) throw new Error('技能包文件数量与清单不符,未安装。')
265
+ if (contentDigest(files) !== manifest.content_sha256) throw new Error('技能整体校验失败,未安装。')
266
+
267
+ const stagingRoot = join(dirname(root), '.ant-skill-staging')
268
+ const stagingStat = await isPresent(stagingRoot)
269
+ if (stagingStat && (!stagingStat.isDirectory() || stagingStat.isSymbolicLink())) throw new Error('ANT 暂存目录不是普通目录。')
270
+ await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
271
+ const staging = join(stagingRoot, `new-${randomUUID()}`)
272
+ const backup = join(stagingRoot, `old-${manifest.dsh_name}-${randomUUID()}`)
273
+ await mkdir(staging, { mode: 0o700 })
274
+ let backedUp = false
275
+ let installedNew = false
276
+ try {
277
+ for (const file of files) {
278
+ const destination = join(staging, ...file.path.split('/'))
279
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 })
280
+ await writeFile(destination, file.content, { flag: 'wx', mode: file.executable ? 0o700 : 0o600 })
281
+ if (file.executable && process.platform !== 'win32') await chmod(destination, 0o700)
282
+ }
283
+ await writeFile(join(staging, MARKER), JSON.stringify({
284
+ dsh_name: manifest.dsh_name, skill_id: skillId, service_url: connection.serviceUrl,
285
+ content_sha256: manifest.content_sha256,
286
+ files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
287
+ }), { flag: 'wx', mode: 0o600 })
288
+ if (oldMarker) { await verifyManagedTree(root, manifest.dsh_name, oldMarker); await rename(target, backup); backedUp = true }
289
+ else if (await isPresent(target)) throw new Error('同名技能已经存在,ANT 不会覆盖。')
290
+ await rename(staging, target)
291
+ installedNew = true
292
+ await assertDiscovered(registry, root, manifest.dsh_name)
293
+ if (backedUp) await rm(backup, { recursive: true }).catch(() => {})
294
+ return { status: oldMarker ? 'updated' : 'installed', dsh_name: manifest.dsh_name }
295
+ } catch (error) {
296
+ if (backedUp && await isPresent(backup)) {
297
+ if (await isPresent(target)) {
298
+ await verifyManagedTree(root, manifest.dsh_name, {
299
+ dsh_name: manifest.dsh_name,
300
+ files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
301
+ }).then(() => rename(target, staging)).catch(() => {})
302
+ }
303
+ if (!(await isPresent(target))) await rename(backup, target).catch(() => {})
304
+ } else if (installedNew) {
305
+ await verifyManagedTree(root, manifest.dsh_name, {
306
+ dsh_name: manifest.dsh_name,
307
+ files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
308
+ }).then(() => rm(target, { recursive: true })).catch(() => {})
309
+ }
310
+ throw error
311
+ } finally { await rm(staging, { recursive: true, force: true }).catch(() => {}) }
312
+ }
313
+
314
+ export async function uninstallSkill(name) {
315
+ if (!safeName(name)) throw new Error('技能名称无效。')
316
+ const { root, conflicts } = await safeRoot()
317
+ if (conflicts.has(name)) throw new Error('本机技能备份已损坏;请联系管理员处理,ANT 不会删除。')
318
+ if (activeSkills.has(name)) throw new Error('这个技能正在安装或卸载,请稍后重试。')
319
+ activeSkills.add(name)
320
+ try { return await uninstallManagedSkill(root, name) }
321
+ finally { activeSkills.delete(name) }
322
+ }
323
+
324
+ async function uninstallManagedSkill(root, name) {
325
+ const marker = await readManaged(root, name)
326
+ if (!marker) return { status: 'not-installed', dsh_name: name }
327
+ await verifyManagedTree(root, name, marker)
328
+ const stagingRoot = join(dirname(root), '.ant-skill-staging')
329
+ const stagingStat = await isPresent(stagingRoot)
330
+ if (stagingStat && (!stagingStat.isDirectory() || stagingStat.isSymbolicLink())) throw new Error('ANT 暂存目录不是普通目录。')
331
+ await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
332
+ const removed = join(stagingRoot, `removed-${name}-${randomUUID()}`)
333
+ await rename(join(root, name), removed)
334
+ try { await rm(removed, { recursive: true }) }
335
+ catch { throw new Error('技能已从 DSH 目录移出,但暂存文件清理失败;请联系管理员处理。') }
336
+ return { status: 'uninstalled', dsh_name: name }
337
+ }