Ver código fonte

feat: 技能推送工具 agent-skills(浏览器设备码登录、预检打包推送、--wait 等待审核、status / withdraw、自动更新)与后台登录审批页、用法页;SDK 与推送工具改由平台自己的 npm 仓库 /npm/ 分发(@latest),不再提供 /sdk/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 15 horas atrás
pai
commit
4eaa33635b
49 arquivos alterados com 5397 adições e 59 exclusões
  1. 3 0
      apps/admin/src/components/CustomSkillsPanel.vue
  2. 15 7
      apps/admin/src/components/SkillPublishKeys.vue
  3. 7 1
      apps/admin/src/lib/api.ts
  4. 2 0
      apps/admin/src/router.ts
  5. 12 7
      apps/admin/src/views/ApiKeysView.vue
  6. 6 2
      apps/admin/src/views/PluginsView.vue
  7. 177 0
      apps/admin/src/views/SkillCliView.vue
  8. 140 0
      apps/admin/src/views/SkillLoginView.vue
  9. 18 0
      apps/server/drizzle/0021_skill_device_login.sql
  10. 3075 0
      apps/server/drizzle/meta/0021_snapshot.json
  11. 7 0
      apps/server/drizzle/meta/_journal.json
  12. 1 1
      apps/server/src/api-reference/bot-reference.ts
  13. 1 1
      apps/server/src/api-reference/reference.ts
  14. 75 1
      apps/server/src/api-reference/registry-reference.ts
  15. 3 1
      apps/server/src/app.ts
  16. 135 0
      apps/server/src/custom-skills/device-login.ts
  17. 17 6
      apps/server/src/custom-skills/registry.ts
  18. 21 0
      apps/server/src/db/schema.ts
  19. 140 0
      apps/server/src/http/npm-registry.ts
  20. 6 0
      apps/server/src/http/routes/admin.ts
  21. 18 0
      apps/server/src/http/routes/registry.ts
  22. 4 13
      apps/server/src/http/server.ts
  23. 1 1
      apps/server/src/lib/ids.ts
  24. 65 0
      apps/server/test/npm-registry.test.ts
  25. 263 0
      apps/server/test/skills-cli.test.ts
  26. 2 1
      deploy/deploy.sh
  27. 3 1
      docs/README.md
  28. 1 1
      docs/adr/0019-browser-tokens-and-sdk-distribution.md
  29. 18 0
      docs/adr/0028-skill-cli-device-login.md
  30. 1 1
      docs/design/04-api.md
  31. 1 1
      docs/design/18-skill-registry.md
  32. 133 0
      docs/design/19-skill-cli.md
  33. 4 5
      docs/integration.md
  34. 2 0
      examples/platform-demo/.npmrc
  35. 1 1
      examples/platform-demo/README.md
  36. 3 3
      examples/platform-demo/package.json
  37. 46 0
      packages/shared/src/api.ts
  38. 22 0
      packages/skills-cli/package.json
  39. 52 0
      packages/skills-cli/src/api.ts
  40. 6 0
      packages/skills-cli/src/bin.ts
  41. 97 0
      packages/skills-cli/src/config.ts
  42. 24 0
      packages/skills-cli/src/git.ts
  43. 500 0
      packages/skills-cli/src/main.ts
  44. 167 0
      packages/skills-cli/src/pack.ts
  45. 43 0
      packages/skills-cli/src/update.ts
  46. 5 0
      packages/skills-cli/tsconfig.json
  47. 17 0
      packages/skills-cli/tsup.config.ts
  48. 19 0
      pnpm-lock.yaml
  49. 18 4
      scripts/build-sdk.mjs

+ 3 - 0
apps/admin/src/components/CustomSkillsPanel.vue

@@ -18,7 +18,9 @@ const pendingCount = ref(0)
 /** 正在审核的提交项(与 current 互斥) */
 const reviewId = ref<string | null>(null)
 const keysPanel = ref<InstanceType<typeof SkillPublishKeys>>()
+const props = defineProps<{ initialReview?: string | null }>()
 const emit = defineEmits<{ pending: [n: number] }>()
+if (props.initialReview) reviewId.value = props.initialReview
 const current = ref<CustomSkillDetailDto | null>(null)
 const busy = ref<string | null>(null)
 
@@ -294,6 +296,7 @@ defineExpose({ load })
       <div class="flex flex-col gap-1">
         <div class="flex items-center gap-1">
           <span class="section-title flex-1">推送的技能</span>
+          <RouterLink to="/skill-cli" class="icon-btn no-underline" title="推送工具用法"><span class="codicon codicon-book" /></RouterLink>
           <button type="button" class="icon-btn" title="刷新" @click="load"><span class="codicon codicon-refresh" /></button>
         </div>
         <div class="flex gap-1 px-1">

+ 15 - 7
apps/admin/src/components/SkillPublishKeys.vue

@@ -6,14 +6,17 @@ import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
 const keys = ref<SkillPublishKeyDto[]>([])
+/** 推送工具的浏览器登录:待批准的申请数 */
+const pendingLogins = ref(0)
 const editor = ref<{ name: string; description: string; expiresInDays: number | null } | null>(null)
 const created = ref<{ key: string; name: string } | null>(null)
 const busy = ref(false)
-const endpoint = `${window.location.origin}/skills/registry/v1/submissions`
+const origin = window.location.origin
+const endpoint = `${origin}/skills/registry/v1/submissions`
 
 async function load() {
   try {
-    keys.value = await admin.skillKeys.list()
+    ;[keys.value, pendingLogins.value] = await Promise.all([admin.skillKeys.list(), admin.skillLogins.pending().then(l => l.length).catch(() => 0)])
   } catch (err) {
     toast(errorMessage(err), 'error')
   }
@@ -58,9 +61,12 @@ async function copy(text: string) {
 }
 
 const expired = (k: SkillPublishKeyDto) => !!k.expiresAt && Date.parse(k.expiresAt) <= Date.now()
-const curl = `cd skills && zip -qr /tmp/skills.zip . && \\
-curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \\
-  -F file=@/tmp/skills.zip -F message="提交说明" \\
+const curl = `# CI:把密钥存为加密变量 AGENT_SKILLS_TOKEN,服务器地址设为 AGENT_SKILLS_SERVER=${origin}
+npx -y --registry ${origin}/npm/ @agent-server/skills-cli@latest push
+
+# 不用推送工具时,也可以直接调用接口:
+cd skills && zip -qr /tmp/skills.zip . && \\
+curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" -F file=@/tmp/skills.zip -F message="提交说明" \\
   ${endpoint}`
 
 onMounted(load)
@@ -81,13 +87,15 @@ defineExpose({ load })
         <button type="button" class="icon-btn" :title="k.enabled ? '停用' : '启用'" @click="toggle(k)"><span class="codicon" :class="k.enabled ? 'codicon-circle-slash' : 'codicon-check'" /></button>
         <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
       </div>
-      <div class="hint font-mono">{{ k.prefix }}…</div>
+      <div class="hint"><span class="font-mono">{{ k.prefix }}…</span><template v-if="k.origin === 'device'"> · 推送工具登录</template></div>
       <div class="hint">
         {{ !k.enabled ? '已停用' : expired(k) ? '已过期' : k.expiresAt ? `${formatTime(k.expiresAt)} 到期` : '永久有效' }}
         · 推送 {{ k.submissions }} 次<template v-if="k.pending"> · <span class="text-warning">待审核 {{ k.pending }}</span></template>
       </div>
       <div v-if="k.lastUsedAt" class="hint">最近使用 {{ formatTime(k.lastUsedAt) }}<template v-if="k.lastUsedIp"> · {{ k.lastUsedIp }}</template></div>
     </div>
+    <RouterLink v-if="pendingLogins" to="/skill-login" class="px-2 text-warning no-underline hover:underline">{{ pendingLogins }} 个推送工具登录申请待批准</RouterLink>
+    <div class="hint px-2">本机推送:执行 <code>npx -y --registry {{ origin }}/npm/ @agent-server/skills-cli@latest login</code>,在浏览器中批准即可(不用复制密钥)。<RouterLink to="/skill-cli">推送工具用法</RouterLink></div>
     <div v-if="!keys.length" class="hint px-2">项目用发布密钥推送技能,审核通过后才会生效。密钥名称表示来源,例如"fwh-app · GitHub Actions"。</div>
 
     <!-- 新建 -->
@@ -124,7 +132,7 @@ defineExpose({ load })
           <code class="flex-1 font-mono text-[12.5px] p-2 rounded-md bg-code break-all select-all">{{ created.key }}</code>
           <button type="button" class="btn-secondary" @click="copy(created.key)"><span class="codicon codicon-copy" />复制</button>
         </div>
-        <div class="hint">把密钥存为环境变量 AGENT_SKILLS_TOKEN(CI 的加密变量,或本机用户目录的配置中),推送示例(在项目目录中执行;地址后加 <code>?dryRun=1</code> 只做校验):</div>
+        <div class="hint">把密钥存为环境变量 AGENT_SKILLS_TOKEN(CI 的加密变量),在项目目录中推送(加 <code>--dry-run</code> 只做校验)。开发机上推荐用 <code>agent-skills login</code> 在浏览器中批准登录,不需要复制密钥。</div>
         <pre class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] whitespace-pre-wrap break-all">{{ curl }}</pre>
         <div class="flex justify-end"><button type="button" class="btn-primary" @click="created = null">我已保存</button></div>
       </div>

+ 7 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
@@ -122,6 +122,12 @@ export const admin = {
     reject: (id: string, reason: string) => req<SkillSubmissionItemDto>('POST', `/skill-submissions/${id}/reject`, { reason }),
     remove: (id: string) => req<void>('DELETE', `/skill-submissions/${id}`),
   },
+  skillLogins: {
+    pending: () => req<SkillDeviceLoginDto[]>('GET', '/skill-logins'),
+    get: (code: string) => req<SkillDeviceLoginDto>('GET', `/skill-logins/${encodeURIComponent(code)}`),
+    approve: (code: string, input: { name: string; expiresInDays: number | null }) => req<SkillDeviceLoginDto>('POST', `/skill-logins/${encodeURIComponent(code)}/approve`, input),
+    deny: (code: string) => req<SkillDeviceLoginDto>('POST', `/skill-logins/${encodeURIComponent(code)}/deny`),
+  },
   serverDirs: (path: string) => req<ServerDirListDto>('GET', `/server-dirs?path=${encodeURIComponent(path)}`),
   mcp: {
     state: () => req<McpStateDto>('GET', '/mcp'),

+ 2 - 0
apps/admin/src/router.ts

@@ -18,6 +18,8 @@ export const router = createRouter({
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
     { path: '/files', component: () => import('./views/FilesView.vue') },
     { path: '/plugins', component: () => import('./views/PluginsView.vue') },
+    { path: '/skill-login', component: () => import('./views/SkillLoginView.vue') },
+    { path: '/skill-cli', component: () => import('./views/SkillCliView.vue') },
     { path: '/mcp', component: () => import('./views/McpView.vue') },
     { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },
     { path: '/api-docs', component: () => import('./views/ApiDocsView.vue') },

+ 12 - 7
apps/admin/src/views/ApiKeysView.vue

@@ -28,11 +28,16 @@ const editor = reactive<{ open: boolean; id: string | null; name: string; allowe
 })
 const editorValid = computed(() => editor.allowed.length > 0 && editor.allowed.includes(editor.def) && (editor.id !== null || editor.name.trim().length > 0))
 
-// 业务平台安装 SDK 的命令(包由本服务的 /sdk/ 提供)
-const sdk = ref<{ version: string; packages: { name: string; file: string }[] } | null>(null)
-const installCmd = computed(() => `pnpm add ${(sdk.value?.packages ?? []).map(p => `${location.origin}/sdk/${p.file}`).join(' ')}`)
+// 业务平台安装 SDK 的命令(包由本服务的 npm 仓库 /npm/ 提供)
+const sdk = ref<{ name: string; version: string; bin: boolean }[] | null>(null)
+// 带 bin 的是命令行工具(技能推送工具 agent-skills),不属于业务平台的 SDK
+const sdkPackages = computed(() => (sdk.value ?? []).filter(p => !p.bin).sort((a, b) => a.name.localeCompare(b.name)))
+const sdkVersion = computed(() => sdkPackages.value.find(p => p.name === '@agent-server/client')?.version ?? '')
+const installCmd = computed(() => `# 项目的 .npmrc:@agent-server 开头的包从本服务安装,其余依赖不变
+echo "@agent-server:registry=${location.origin}/npm/" >> .npmrc
+pnpm add ${sdkPackages.value.map(p => p.name).join(' ')}`)
 async function load() {
-  fetch('/sdk/index.json').then(r => (r.ok ? r.json() : null)).then(v => (sdk.value = v)).catch(() => {})
+  fetch('/npm/-/latest').then(r => (r.ok ? r.json() : null)).then(v => (sdk.value = v)).catch(() => {})
   try {
     ;[keys.value, profiles.value] = await Promise.all([admin.apiKeys(), admin.profiles()])
   } catch (err) {
@@ -166,12 +171,12 @@ onMounted(load)
           <li>在"设置 › 安全"的 CORS 来源中加入平台的地址(如 <code class="font-mono">https://tools.example.com</code>)。</li>
           <li>前端安装 SDK,使用 <code class="font-mono">&lt;AgentChat&gt;</code> 组件;详见仓库中的 docs/integration.md 与 examples/platform-demo。</li>
         </ol>
-        <template v-if="sdk">
+        <template v-if="sdkPackages.length">
           <div class="flex items-center gap-2 mt-1">
-            <span class="hint">安装 SDK {{ sdk.version }}:</span>
+            <span class="hint">安装 SDK {{ sdkVersion }}(升级:pnpm update "@agent-server/*" --latest):</span>
             <button type="button" class="btn-ghost" @click="copy(installCmd)"><span class="codicon codicon-copy" />复制</button>
           </div>
-          <code class="block rounded-md bg-code p-2 font-mono text-[12px] break-all">{{ installCmd }}</code>
+          <code class="block rounded-md bg-code p-2 font-mono text-[12px] break-all whitespace-pre-wrap">{{ installCmd }}</code>
         </template>
         <div v-else class="hint">当前服务没有提供 SDK 包(部署时由 scripts/build-sdk.mjs 生成)</div>
       </section>

+ 6 - 2
apps/admin/src/views/PluginsView.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
 import type {
   AvailablePluginDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
 } from '@agent-server/shared'
@@ -11,7 +12,10 @@ import { formatTime, toast } from '../lib/ui'
 const OFFICIAL_SOURCE = 'anthropics/skills'
 
 // 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
-const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community' | 'custom'>('claude')
+// 推送工具给出的审核链接:?tab=custom&review=<提交项 ID>
+const route = useRoute()
+const reviewFromLink = typeof route.query.review === 'string' ? route.query.review : null
+const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community' | 'custom'>(route.query.tab === 'custom' || reviewFromLink ? 'custom' : 'claude')
 const codex = ref<CodexSkillCatalogDto | null>(null)
 const codexBusy = ref<string | null>(null)
 const codexSearch = ref('')
@@ -259,7 +263,7 @@ onMounted(() => {
         :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
       >{{ t[1] }}<span v-if="t[0] === 'custom' && pendingSkills" class="ml-1.5 px-1.5 rounded-full bg-accent text-white text-[11px]" :title="`${pendingSkills} 个推送的技能待审核`">{{ pendingSkills }}</span></button>
     </div>
-    <CustomSkillsPanel v-if="tab === 'custom'" @pending="n => (pendingSkills = n)" />
+    <CustomSkillsPanel v-if="tab === 'custom'" :initial-review="reviewFromLink" @pending="n => (pendingSkills = n)" />
     <div v-else class="flex-1 overflow-y-auto">
       <!-- Codex 插件:openai-curated 目录,只在 Playground 中使用 -->
       <div v-if="tab === 'codex-plugins'" class="max-w-240 p-6 flex flex-col gap-5">

+ 177 - 0
apps/admin/src/views/SkillCliView.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+// 推送工具 agent-skills 的用法(docs/design/19-skill-cli.md):给管理员转发给项目方,命令中的服务器地址取当前地址
+import PageHeader from '../components/PageHeader.vue'
+import { toast } from '../lib/ui'
+
+const origin = window.location.origin
+const registry = `${origin}/npm/`
+const run = `npx -y --registry ${registry} @agent-server/skills-cli@latest`
+
+async function copy(text: string) {
+  try {
+    await navigator.clipboard.writeText(text)
+    toast('已复制', 'success')
+  } catch {
+    toast('复制失败,请手动选择复制', 'error')
+  }
+}
+
+const blocks = {
+  quick: `# 1. 登录本机:终端显示验证码和链接,在浏览器中打开链接、由管理员核对后批准
+${run} login --server ${origin}
+
+# 2. 在项目根目录生成配置(服务器地址、技能目录;不含密钥,可以提交到仓库)
+cd <项目目录>
+${run} init
+
+# 3. 推送:先试运行看看会推送什么,再正式推送;--wait 等待审核结果
+${run} push --dry-run
+${run} push --wait`,
+  npmrc: `# 项目或用户目录的 .npmrc 中加一行,之后可以省略 --registry
+@agent-server:registry=${registry}
+
+npx -y @agent-server/skills-cli@latest push`,
+  global: `npm i -g --registry ${registry} @agent-server/skills-cli@latest    # 再执行一次即更新
+agent-skills --help`,
+  layout: `<项目>/
+├── .agent-skills.json        # init 生成
+└── skills/
+    ├── order-query/
+    │   ├── SKILL.md          # 开头的 name、description 必填,name 与目录名一致
+    │   └── references/…
+    └── report-format/
+        └── SKILL.md`,
+  config: `{
+  "server": "${origin}",
+  "paths": ["skills"],
+  "exclude": ["**/*.log", "drafts/**"]
+}`,
+  github: `- name: 推送技能
+  if: github.event_name == 'push'
+  run: ${run} push
+  env:
+    AGENT_SKILLS_TOKEN: \${{ secrets.AGENT_SKILLS_TOKEN }}
+    AGENT_SKILLS_SERVER: ${origin}`,
+  gitlab: `push-skills:
+  image: node:22
+  rules: [{ changes: ["skills/**/*"] }]
+  script: ${run} push
+  variables: { AGENT_SKILLS_SERVER: ${origin} }   # AGENT_SKILLS_TOKEN 设为受保护的 CI 变量`,
+}
+
+const COMMANDS: [string, string][] = [
+  ['login [--server 地址]', '浏览器登录,密钥保存在用户目录(~/.config/agent-skills/,权限 600)。SSH 远程终端也可以:在任意电脑的浏览器中打开显示的地址'],
+  ['login --with-token', '从标准输入读取后台创建的发布密钥,例如 echo $KEY | … login --with-token --server 地址'],
+  ['logout', '在服务器上停用当前密钥,并删除本机保存的密钥'],
+  ['whoami', '当前使用的密钥:名称、来源、到期时间'],
+  ['init', '在当前目录生成 .agent-skills.json'],
+  ['push [目录…]', '推送技能。--only 名称:只推送指定的技能;-m 说明:提交说明(默认取最近一次 git 提交的标题);--dry-run:只校验;--wait:等待审核;--timeout 秒数:等待的最长时间(默认 600);--strict:疑似密钥也算错误;--json:输出 JSON'],
+  ['status [提交ID]', '最近 10 次提交,或某次提交的审核结果(含拒绝原因)'],
+  ['withdraw <提交ID>', '撤回仍在待审核的技能'],
+]
+const EXIT: [string, string][] = [
+  ['0', '成功:已推送(待审核)或无变化;--wait 时全部批准或撤回'],
+  ['1', '校验不通过:本地预检、服务器判定不能导入、参数错误'],
+  ['2', '鉴权失败:没有密钥,或密钥无效、停用、过期;登录被拒绝或过期'],
+  ['3', '推送过于频繁、待审核过多,或网络、服务器错误'],
+  ['4', '--wait:有技能被拒绝'],
+  ['5', '--wait:等待超时,仍在待审核'],
+]
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="推送工具用法" subtitle="项目用 agent-skills 把技能推送到平台,审核通过后生效。可以把本页地址发给项目方" />
+    <div class="flex-1 overflow-y-auto">
+      <article class="doc max-w-200 mx-auto px-8 py-6">
+        <p>
+          <code>agent-skills</code> 是一个 Node 命令行工具(需要 Node 18 以上,包名 <code>@agent-server/skills-cli</code>),由本平台的 npm 仓库
+          <code>{{ registry }}</code> 提供,不需要安装:用 <code>npx</code> 直接运行。命令中的 <code>@latest</code> 让 npx 每次向仓库确认最新版本,平台更新推送工具后自动用新版本。
+        </p>
+
+        <h2>本机推送(开发机)</h2>
+        <div class="block">
+          <pre>{{ blocks.quick }}</pre>
+          <button type="button" class="icon-btn copy" title="复制" @click="copy(blocks.quick)"><span class="codicon codicon-copy" /></button>
+        </div>
+        <ul>
+          <li>登录时<b>由管理员在浏览器中批准</b>(地址为 <RouterLink to="/skill-login">/admin/skill-login</RouterLink>),不用复制密钥。批准后签发的发布密钥在"自定义技能 › 发布密钥"中标为"推送工具登录",可以停用、删除。</li>
+          <li>推送后显示每个技能的状态和审核链接;管理员批准后技能才会生效。<code>--wait</code> 时终端会一直等到审核完成(被拒绝时显示原因)。</li>
+          <li>嫌命令长的话,在 <code>.npmrc</code> 中登记仓库地址:</li>
+        </ul>
+        <div class="block">
+          <pre>{{ blocks.npmrc }}</pre>
+          <button type="button" class="icon-btn copy" title="复制" @click="copy(blocks.npmrc)"><span class="codicon codicon-copy" /></button>
+        </div>
+        <ul>
+          <li>经常推送的话可以全局安装,之后直接用 <code>agent-skills</code>(全局安装的版本较旧时,执行命令会自动改用新版本并提示更新):</li>
+        </ul>
+        <div class="block">
+          <pre>{{ blocks.global }}</pre>
+          <button type="button" class="icon-btn copy" title="复制" @click="copy(blocks.global)"><span class="codicon codicon-copy" /></button>
+        </div>
+
+        <h2>技能目录与项目配置</h2>
+        <p>每个技能一个目录,目录中有 <code>SKILL.md</code>。工具能识别:当前目录就是一个技能、<code>&lt;名称&gt;/SKILL.md</code>、<code>skills/&lt;名称&gt;/SKILL.md</code>。</p>
+        <pre>{{ blocks.layout }}</pre>
+        <p><code>.agent-skills.json</code>(<code>init</code> 生成,在子目录中执行也会向上找到它):</p>
+        <pre>{{ blocks.config }}</pre>
+        <ul>
+          <li>推送前在本地检查:技能名、<code>SKILL.md</code> 的 name 与 description、文件数量与大小。有错误时不推送;疑似密码或密钥给出警告。</li>
+          <li>默认不推送 <code>.git</code>、<code>node_modules</code>、<code>__pycache__</code>、<code>.venv</code>、<code>*.pyc</code>、<code>.env</code>(保留 <code>.env.example</code>)和符号链接;<code>exclude</code> 可以追加(<code>*</code> 不跨目录、<code>**</code> 跨目录、<code>!</code> 开头表示不排除)。</li>
+          <li>提交说明默认取最近一次 git 提交的标题,来源信息(仓库、分支、提交号)自动附带,审核时可以看到。</li>
+        </ul>
+
+        <h2>CI 中推送</h2>
+        <p>
+          CI 不能在浏览器中登录:在"技能与插件 › 自定义技能 › 发布密钥"中新建一个密钥(名称写明来源,例如"fwh-app · GitHub Actions"),
+          存为 CI 的加密变量 <code>AGENT_SKILLS_TOKEN</code>。CI 中一般不加 <code>--wait</code>:审核依赖人,等待会占用运行器。
+        </p>
+        <h4>GitHub Actions</h4>
+        <div class="block">
+          <pre>{{ blocks.github }}</pre>
+          <button type="button" class="icon-btn copy" title="复制" @click="copy(blocks.github)"><span class="codicon codicon-copy" /></button>
+        </div>
+        <h4>GitLab CI</h4>
+        <div class="block">
+          <pre>{{ blocks.gitlab }}</pre>
+          <button type="button" class="icon-btn copy" title="复制" @click="copy(blocks.gitlab)"><span class="codicon codicon-copy" /></button>
+        </div>
+
+        <h2>命令</h2>
+        <table>
+          <tbody><tr v-for="c in COMMANDS" :key="c[0]"><td class="whitespace-nowrap"><code>{{ c[0] }}</code></td><td>{{ c[1] }}</td></tr></tbody>
+        </table>
+        <p class="muted">通用选项:<code>--server 地址</code>、<code>--json</code>、<code>-h</code>/<code>--help</code>、<code>-v</code>/<code>--version</code>。</p>
+
+        <h2>退出码</h2>
+        <table>
+          <tbody><tr v-for="e in EXIT" :key="e[0]"><td class="w-12"><code>{{ e[0] }}</code></td><td>{{ e[1] }}</td></tr></tbody>
+        </table>
+
+        <h2>密钥、服务器与更新</h2>
+        <ul>
+          <li>读取顺序:命令行参数 → 环境变量 <code>AGENT_SKILLS_TOKEN</code>、<code>AGENT_SKILLS_SERVER</code> → 项目的 <code>.agent-skills.json</code>(只有服务器地址)→ <code>login</code> 保存在用户目录的密钥。</li>
+          <li>发布密钥只能产生待审核的推送,不能直接修改已生效的技能。</li>
+          <li>更新:用 <code>@latest</code> 运行时每次都是最新版本。全局安装或锁定了版本时,每次执行前查询仓库中的最新版本,有新版本时改用新版本重新执行同一条命令;查询失败时继续用当前版本。设置环境变量 <code>AGENT_SKILLS_NO_UPDATE_CHECK=1</code> 可以关闭。</li>
+          <li>直接调用接口(不用推送工具)见 <RouterLink to="/api-docs?api=registry">API 协议 › 技能发布 API</RouterLink>。</li>
+        </ul>
+      </article>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.doc { line-height: 1.65; font-size: 12.5px; }
+.doc h2 { font-size: 16px; margin: 28px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--app-primary-border-color); }
+.doc h4 { font-size: 12px; margin: 12px 0 4px; color: var(--app-secondary-foreground); }
+.doc p, .doc ul { margin: 6px 0; }
+.doc code { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 1px 5px; border-radius: 4px; }
+.doc pre { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 10px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; margin: 6px 0; }
+.doc table { border-collapse: collapse; width: 100%; margin: 6px 0; }
+.doc td { border: 1px solid var(--app-primary-border-color); padding: 4px 8px; text-align: left; vertical-align: top; }
+.doc a { color: var(--app-link-foreground, var(--app-accent)); }
+.muted { color: var(--app-secondary-foreground); }
+.block { position: relative; }
+.block .copy { position: absolute; top: 4px; right: 4px; }
+</style>

+ 140 - 0
apps/admin/src/views/SkillLoginView.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+// 推送工具的浏览器登录审批(docs/design/19-skill-cli.md):终端执行 agent-skills login 后显示验证码与本页地址,
+// 管理员核对验证码与申请信息后批准,终端随即取得发布密钥
+import { computed, onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { SkillDeviceLoginDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const route = useRoute()
+const router = useRouter()
+const codeInput = ref(typeof route.query.code === 'string' ? route.query.code : '')
+const login = ref<SkillDeviceLoginDto | null>(null)
+const pending = ref<SkillDeviceLoginDto[]>([])
+const name = ref('')
+const days = ref<number | null>(365)
+const busy = ref(false)
+const notFound = ref<string | null>(null)
+
+async function loadPending() {
+  pending.value = await admin.skillLogins.pending().catch(() => [])
+}
+async function open(code: string) {
+  notFound.value = null
+  login.value = null
+  if (!code.trim()) return
+  try {
+    const l = await admin.skillLogins.get(code.trim())
+    login.value = l
+    name.value = l.keyName ?? l.defaultName
+    codeInput.value = l.userCode
+    if (route.query.code !== l.userCode) void router.replace({ query: { code: l.userCode } })
+  } catch (err) {
+    notFound.value = errorMessage(err)
+  }
+}
+async function decide(approve: boolean) {
+  const l = login.value
+  if (!l) return
+  busy.value = true
+  try {
+    login.value = approve
+      ? await admin.skillLogins.approve(l.userCode, { name: name.value, expiresInDays: days.value })
+      : await admin.skillLogins.deny(l.userCode)
+    toast(approve ? '已批准,终端会在几秒内完成登录' : '已拒绝', approve ? 'success' : 'info')
+    await loadPending()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = false
+  }
+}
+
+const STATUS: Record<SkillDeviceLoginDto['status'], [string, string]> = {
+  pending: ['待批准', 'text-warning'], approved: ['已批准,等待终端取得密钥', 'text-success'], consumed: ['已批准,终端已登录', 'text-success'],
+  denied: ['已拒绝', 'text-error'], expired: ['已过期', 'text-fg-muted'],
+}
+const remaining = computed(() => (login.value ? Math.max(0, Math.round((Date.parse(login.value.expiresAt) - Date.now()) / 60000)) : 0))
+
+onMounted(async () => {
+  await loadPending()
+  if (codeInput.value) await open(codeInput.value)
+})
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="推送工具登录" subtitle="在终端执行 agent-skills login 后,核对验证码并批准,终端即可推送技能" />
+    <div class="flex-1 overflow-y-auto">
+      <div class="max-w-160 mx-auto p-6 flex flex-col gap-5 text-[12.5px]">
+        <form class="flex items-end gap-2" @submit.prevent="open(codeInput)">
+          <div class="flex-1">
+            <label class="label" for="sl-code">验证码(终端中显示)</label>
+            <input id="sl-code" v-model="codeInput" class="field font-mono uppercase tracking-widest" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false">
+          </div>
+          <button type="submit" class="btn-secondary">查找</button>
+        </form>
+        <div v-if="notFound" class="text-error">{{ notFound }}</div>
+
+        <div v-if="login" class="card p-4 flex flex-col gap-3">
+          <div class="flex items-center gap-2">
+            <span class="font-mono font-700 text-[18px] tracking-widest">{{ login.userCode }}</span>
+            <span class="badge bg-code" :class="STATUS[login.status][1]">{{ STATUS[login.status][0] }}</span>
+            <span v-if="login.status === 'pending'" class="hint">{{ remaining }} 分钟内有效</span>
+          </div>
+          <table class="w-full border-collapse">
+            <tbody>
+              <tr><td class="py-1 pr-4 text-fg-muted w-24">机器</td><td class="font-mono">{{ login.client.host || '(未提供)' }}</td></tr>
+              <tr><td class="py-1 pr-4 text-fg-muted">系统用户</td><td class="font-mono">{{ login.client.user || '(未提供)' }}</td></tr>
+              <tr><td class="py-1 pr-4 text-fg-muted">来源 IP</td><td class="font-mono">{{ login.ip }}</td></tr>
+              <tr><td class="py-1 pr-4 text-fg-muted">系统</td><td class="font-mono">{{ login.client.platform }} · agent-skills {{ login.client.cliVersion }}</td></tr>
+              <tr><td class="py-1 pr-4 text-fg-muted">申请时间</td><td>{{ formatTime(login.createdAt) }}</td></tr>
+            </tbody>
+          </table>
+
+          <template v-if="login.status === 'pending'">
+            <div class="rounded-md border border-solid border-warning p-2.5 text-warning">
+              只在你本人刚刚在终端执行了 agent-skills login、并且终端显示的验证码与上面一致时批准。不要批准别人发来的链接。
+            </div>
+            <div>
+              <label class="label" for="sl-name">密钥名称(表示推送的来源)</label>
+              <input id="sl-name" v-model="name" class="field">
+            </div>
+            <div>
+              <label class="label" for="sl-days">有效期</label>
+              <select id="sl-days" v-model="days" class="field w-40">
+                <option :value="90">90 天</option>
+                <option :value="365">1 年</option>
+                <option :value="730">2 年</option>
+                <option :value="null">永久</option>
+              </select>
+            </div>
+            <div class="hint">批准后会签发一个发布密钥,只保存在这台机器的用户目录中;它只能推送待审核的技能。可以在"技能与插件 › 自定义技能 › 发布密钥"中停用或删除。</div>
+            <div class="flex gap-2">
+              <button type="button" class="btn-primary" :disabled="busy || !name.trim()" @click="decide(true)">批准</button>
+              <button type="button" class="btn-secondary" :disabled="busy" @click="decide(false)">拒绝</button>
+            </div>
+          </template>
+          <div v-else-if="login.keyName" class="hint">密钥名称:{{ login.keyName }}</div>
+        </div>
+
+        <div v-if="pending.length" class="flex flex-col gap-1">
+          <div class="section-title">待批准的登录申请</div>
+          <button
+            v-for="p in pending" :key="p.userCode" type="button"
+            class="text-left border-none rounded-md px-2 py-1.5 cursor-pointer flex items-center gap-3" :class="login?.userCode === p.userCode ? 'bg-active' : 'bg-transparent hover:bg-hover'"
+            @click="open(p.userCode)"
+          >
+            <span class="font-mono tracking-widest">{{ p.userCode }}</span>
+            <span class="font-mono">{{ p.client.user }}@{{ p.client.host }}</span>
+            <span class="hint">{{ p.ip }} · {{ formatTime(p.createdAt) }}</span>
+          </button>
+        </div>
+        <div v-else-if="!login" class="hint">没有待批准的登录申请。在终端执行 <code>agent-skills login --server {{ '<服务器地址>' }}</code> 后,按提示打开本页。</div>
+        <RouterLink to="/skill-cli" class="hint">推送工具用法</RouterLink>
+      </div>
+    </div>
+  </div>
+</template>

+ 18 - 0
apps/server/drizzle/0021_skill_device_login.sql

@@ -0,0 +1,18 @@
+CREATE TABLE `skill_device_logins` (
+	`id` text PRIMARY KEY NOT NULL,
+	`device_code_hash` text NOT NULL,
+	`user_code` text NOT NULL,
+	`client` text NOT NULL,
+	`ip` text NOT NULL,
+	`status` text DEFAULT 'pending' NOT NULL,
+	`key_name` text,
+	`key_days` integer,
+	`key_id` text,
+	`expires_at` text NOT NULL,
+	`last_poll_at` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `skill_device_logins_device_code_hash_unique` ON `skill_device_logins` (`device_code_hash`);--> statement-breakpoint
+CREATE UNIQUE INDEX `skill_device_logins_user_code_unique` ON `skill_device_logins` (`user_code`);--> statement-breakpoint
+ALTER TABLE `skill_publish_keys` ADD `origin` text DEFAULT 'manual' NOT NULL;

+ 3075 - 0
apps/server/drizzle/meta/0021_snapshot.json

@@ -0,0 +1,3075 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "0dad397c-3f8a-4806-b33e-36066a92e03e",
+  "prevId": "2af77c7c-70bc-43d1-9b96-f00f95309921",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "mode": {
+          "name": "mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'wait'"
+        },
+        "mcp_task_id": {
+          "name": "mcp_task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "conn_scope": {
+          "name": "conn_scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_after": {
+          "name": "poll_after",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_error_since": {
+          "name": "poll_error_since",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status_message": {
+          "name": "status_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_hints": {
+          "name": "tool_hints",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "oauth": {
+          "name": "oauth",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "features": {
+          "name": "features",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "codex_plugin_ids": {
+          "name": "codex_plugin_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_device_logins": {
+      "name": "skill_device_logins",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "device_code_hash": {
+          "name": "device_code_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_code": {
+          "name": "user_code",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "client": {
+          "name": "client",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ip": {
+          "name": "ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_days": {
+          "name": "key_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_poll_at": {
+          "name": "last_poll_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "skill_device_logins_device_code_hash_unique": {
+          "name": "skill_device_logins_device_code_hash_unique",
+          "columns": [
+            "device_code_hash"
+          ],
+          "isUnique": true
+        },
+        "skill_device_logins_user_code_unique": {
+          "name": "skill_device_logins_user_code_unique",
+          "columns": [
+            "user_code"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_publish_keys": {
+      "name": "skill_publish_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_used_ip": {
+          "name": "last_used_ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "skill_publish_keys_key_hash_unique": {
+          "name": "skill_publish_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submission_items": {
+      "name": "skill_submission_items",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "submission_id": {
+          "name": "submission_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "declared_version": {
+          "name": "declared_version",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "checks": {
+          "name": "checks",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "review_note": {
+          "name": "review_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reviewed_at": {
+          "name": "reviewed_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submissions": {
+      "name": "skill_submissions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "message": {
+          "name": "message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "meta": {
+          "name": "meta",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "package_sha": {
+          "name": "package_sha",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "package_size": {
+          "name": "package_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -148,6 +148,13 @@
       "when": 1793697036854,
       "tag": "0020_drop_skill_sources",
       "breakpoints": true
+    },
+    {
+      "idx": 21,
+      "version": "6",
+      "when": 1793700306445,
+      "tag": "0021_skill_device_login",
+      "breakpoints": true
     }
   ]
 }

+ 1 - 1
apps/server/src/api-reference/bot-reference.ts

@@ -15,7 +15,7 @@ export const BOT_OVERVIEW: ApiOverview = {
     '**任务**:复杂工作由组长开始一项**任务**(job),拆成**子任务**(task)分派给组员,组员完成后组长汇总交付。同一工作组同一时间只有一项任务;进度见 `job.updated` / `task.updated` 事件和 `GET /bot/v1/teams/:id/jobs`。超出限额时任务暂停,由成员选择继续或取消。',
     '**ID 前缀**:工作组 `team_`、机器人 `bot_`、消息 `tmsg_`、任务 `job_`、子任务 `task_`、交互 `itx_`、文件 `file_`;机器人的执行过程是会话 `ses_` 与 Run `run_`。',
     '**错误格式**与 Agent API 相同:`{ "error": { "code": "…", "message": "…", "details": {} } }`。',
-    '**SDK**:`@agent-server/client` 中的 `BotClient` 与 `@agent-server/chat-ui` 中的 `<TeamChat>`,安装地址见 `$BASE/sdk/index.json`。',
+    '**SDK**:`@agent-server/client` 中的 `BotClient` 与 `@agent-server/chat-ui` 中的 `<TeamChat>`,从平台的 npm 仓库 `$BASE/npm/` 安装(见 Agent API 的 SDK 说明)。',
   ],
   flow: [
     '平台后端用 Bot Key 换取浏览器令牌;前端 `GET /bot/v1/bots` 查看可用的机器人;',

+ 1 - 1
apps/server/src/api-reference/reference.ts

@@ -19,7 +19,7 @@ export const API_OVERVIEW: ApiOverview = {
     '**ID 前缀**:会话 `ses_`、Run `run_`、交互 `itx_`、文件 `file_`、Profile `prf_`。',
     '**错误格式**:`{ "error": { "code": "session_busy", "message": "…", "details": {} } }`,错误码见最后一节。',
     '**跨域**:浏览器直接调用时,需在后台"设置 › 安全 › CORS 来源"中加入平台地址;请求不携带 Cookie。',
-    '**SDK**:TypeScript 客户端与 Vue 对话组件,安装地址见 `$BASE/sdk/index.json`,用法见接入指南(docs/integration.md)。',
+    '**SDK**:TypeScript 客户端与 Vue 对话组件,从平台的 npm 仓库安装(项目 `.npmrc` 中加 `@agent-server:registry=$BASE/npm/`,再 `pnpm add @agent-server/client @agent-server/chat-ui @agent-server/shared`),用法见接入指南(docs/integration.md)。',
   ],
   flow: [
     '平台后端换取浏览器令牌,前端 `POST /v1/sessions` 创建会话;',

+ 75 - 1
apps/server/src/api-reference/registry-reference.ts

@@ -8,14 +8,17 @@ export const REGISTRY_OVERVIEW: ApiOverview = {
     '**用途**:项目把自己维护或生成的技能推送到平台,成为自定义技能(两个引擎共用,在 Profile 中勾选后使用)。推送**不会直接改变任何技能**:每个技能都要由管理员审核,批准为新技能或覆盖已有技能后才生效。',
     '**基础地址**:`$BASE`,所有接口位于 `/skills/registry/v1` 下;响应为 JSON(UTF-8),时间为 ISO 8601。',
     '**鉴权**:`Authorization: Bearer <发布密钥>`。发布密钥(`sk_pub_…`)在后台"技能与插件 › 自定义技能 › 发布密钥"中创建,**名称表示推送的来源**(例如"fwh-app · GitHub Actions"),有有效期。只能调用本组接口,与 Agent Key、Bot Key 互不通用。',
+    '**推送工具**:推荐使用命令行工具 `agent-skills`(Node 18+,包名 `@agent-server/skills-cli`,由平台的 npm 仓库提供,不需要安装:`npx -y --registry $BASE/npm/ @agent-server/skills-cli@latest <命令>`),它封装了本组接口:`login` 在浏览器中批准后登录本机,`push` 预检、打包并推送,`status` 查看审核结果。用法见后台"技能与插件 › 自定义技能 › 推送工具用法"(`/admin/skill-cli`)或 `agent-skills --help`。',
     '**密钥保管**:存为 CI 的加密变量,或本机用户目录配置中的环境变量(如 `AGENT_SKILLS_TOKEN`),不要提交到代码仓库。密钥只能产生待审核的提交,泄露时在后台停用或删除即可。',
     '**技能包**:zip,可以是单个技能(根目录有 `SKILL.md`)、技能集合(`<名称>/SKILL.md`)或插件格式(`skills/<名称>/SKILL.md`);根目录只有一个文件夹时会先进入它。技能名取 SKILL.md 中的 `name`(缺省用目录名),只能用小写字母、数字和连字符。只取普通文件,符号链接与不安全的路径会被跳过。',
     '**限额**:技能包 ≤ 50 MB;单个技能 ≤ 500 个文件、≤ 20 MB,单个文件 ≤ 10 MB;每个密钥每小时最多推送 30 次,待审核的技能最多 50 个。',
     '**可见范围**:推送方只能看到用自己的密钥提交的内容,看不到其他密钥的提交,也不能下载平台上的技能。',
-    '**ID 前缀**:提交 `sub_`、提交项 `sbi_`。',
+    '**浏览器登录**(设备码授权,RFC 8628):开发机上不用复制密钥。终端调用 `POST /device/code` 得到验证码和确认地址;管理员在浏览器中核对验证码后批准;终端每隔 `interval` 秒调用 `POST /device/token`,批准后得到发布密钥(只返回一次)。CI 请使用后台创建的密钥。',
+    '**ID 前缀**:提交 `sub_`、提交项 `sbi_`、浏览器登录的设备码 `sdc_`。',
     '**错误格式**:`{ "error": { "code": "invalid_key", "message": "…" } }`,错误码见最后一节。',
   ],
   flow: [
+    '开发机:`agent-skills login` 在浏览器中批准后登录;CI:把后台创建的密钥存为加密变量 `AGENT_SKILLS_TOKEN`;',
     '(可选)`POST /skills/registry/v1/submissions?dryRun=1` 只做校验,在 CI 中先检查技能包;',
     '`POST /skills/registry/v1/submissions` 推送技能包,附提交说明与来源信息,返回每个技能的状态(待审核 / 无变化 / 不能导入);',
     '管理员在后台审核:批准为新技能(可以改名)、覆盖已有技能,或拒绝并填写原因;',
@@ -25,6 +28,47 @@ export const REGISTRY_OVERVIEW: ApiOverview = {
 }
 
 export const REGISTRY_GROUPS: ApiEndpointGroup[] = [
+  {
+    id: 'login',
+    title: '登录与密钥',
+    desc: '`device/code` 与 `device/token` 不需要密钥;错误码与 OAuth 设备码授权(RFC 8628)一致,推送工具按错误码决定继续等待还是结束。',
+    endpoints: [
+      {
+        method: 'POST', path: '/skills/registry/v1/device/code', summary: '申请浏览器登录',
+        desc: '返回设备码(只给终端,用于询问结果)和验证码(给人在浏览器中核对)。申请 10 分钟内有效;每个来源 IP 10 分钟内最多申请 10 次。',
+        body: [
+          { name: 'host', type: 'string', desc: '机器名(显示在审批页)' },
+          { name: 'user', type: 'string', desc: '系统用户(显示在审批页,并作为默认的密钥名称)' },
+          { name: 'platform', type: 'string', desc: '系统,如 linux-x64' },
+          { name: 'cliVersion', type: 'string', desc: '推送工具版本' },
+        ],
+        sampleBody: { host: 'dev-box', user: 'alice', platform: 'linux-x64', cliVersion: '0.1.0' },
+        response: { status: 201, type: 'DeviceCode' },
+        errors: ['429 rate_limited:申请过于频繁'],
+      },
+      {
+        method: 'POST', path: '/skills/registry/v1/device/token', summary: '询问登录结果',
+        desc: '按 `interval` 秒的间隔调用。批准后签发发布密钥并返回明文(只返回这一次,之后同一设备码不能再换取)。',
+        body: [{ name: 'deviceCode', type: 'string', required: true, desc: '`device/code` 返回的设备码' }],
+        response: { status: 200, type: 'DeviceToken' },
+        errors: [
+          '400 authorization_pending:还没有批准,继续等待', '400 slow_down:询问过于频繁,把间隔加 5 秒',
+          '400 access_denied:管理员拒绝了登录', '400 expired_token:申请已过期或已使用,请重新申请', '400 invalid_device_code:设备码不正确',
+        ],
+      },
+      {
+        method: 'GET', path: '/skills/registry/v1/me', summary: '当前密钥',
+        desc: '推送工具的 `whoami`,也用于检查密钥是否有效。',
+        response: { status: 200, type: 'PublishKeyInfo' },
+        errors: ['401 invalid_key:密钥无效、已停用或已过期'],
+      },
+      {
+        method: 'DELETE', path: '/skills/registry/v1/me', summary: '停用当前密钥',
+        desc: '推送工具的 `logout`:在服务器上停用当前密钥(之后在后台可以重新启用或删除)。',
+        response: { status: 204, type: '—' },
+      },
+    ],
+  },
   {
     id: 'submissions',
     title: '推送与查询',
@@ -74,6 +118,32 @@ curl -X POST $BASE/skills/registry/v1/submissions \\
 ]
 
 export const REGISTRY_TYPES: ApiTypeDef[] = [
+  {
+    name: 'DeviceCode', desc: '浏览器登录申请', fields: [
+      { name: 'deviceCode', type: 'string', desc: 'sdc_ 开头,只给终端,用于询问结果' },
+      { name: 'userCode', type: 'string', desc: '验证码,如 `WDJB-MJHT`,显示给人在浏览器中核对' },
+      { name: 'verificationUri', type: 'string', desc: '审批页地址' },
+      { name: 'verificationUriComplete', type: 'string', desc: '带验证码的审批页地址(直接打开)' },
+      { name: 'expiresIn', type: 'number', desc: '有效期(秒)' },
+      { name: 'interval', type: 'number', desc: '询问间隔(秒)' },
+    ],
+  },
+  {
+    name: 'DeviceToken', desc: '批准后签发的发布密钥(只返回一次)', fields: [
+      { name: 'token', type: 'string', desc: 'sk_pub_ 开头' },
+      { name: 'name', type: 'string', desc: '密钥名称(管理员批准时确定)' },
+      { name: 'expiresAt', type: 'string | null', desc: '到期时间;null 为永久' },
+    ],
+  },
+  {
+    name: 'PublishKeyInfo', fields: [
+      { name: 'name', type: 'string', desc: '密钥名称(推送的来源)' },
+      { name: 'prefix', type: 'string', desc: '密钥前缀' },
+      { name: 'expiresAt', type: 'string | null', desc: '到期时间;null 为永久' },
+      { name: 'origin', type: '"manual" | "device"', desc: '后台创建 / 浏览器登录签发' },
+      { name: 'createdAt', type: 'string', desc: '' },
+    ],
+  },
   {
     name: 'RegistrySubmission', desc: '一次推送', fields: [
       { name: 'id', type: 'string', desc: 'sub_ 开头;试运行时为空字符串' },
@@ -114,6 +184,10 @@ export const REGISTRY_ERRORS: ApiErrorDef[] = [
   { status: 400, code: 'bad_request', desc: '请求不正确(不是有效的 zip、没有找到技能、超过大小上限等)' },
   { status: 401, code: 'unauthorized', desc: '缺少发布密钥' },
   { status: 401, code: 'invalid_key', desc: '发布密钥无效、已停用或已过期' },
+  { status: 400, code: 'authorization_pending', desc: '浏览器登录:还没有批准' },
+  { status: 400, code: 'slow_down', desc: '浏览器登录:询问过于频繁' },
+  { status: 400, code: 'access_denied', desc: '浏览器登录:被拒绝' },
+  { status: 400, code: 'expired_token', desc: '浏览器登录:申请已过期或已使用' },
   { status: 404, code: 'not_found', desc: '提交不存在,或不是用当前密钥推送的' },
   { status: 409, code: 'package_changed', desc: '技能包在处理过程中发生了变化,请重新推送' },
   { status: 413, code: 'file_too_large', desc: '技能包超过 50 MB' },

+ 3 - 1
apps/server/src/app.ts

@@ -28,6 +28,7 @@ import { McpServerService } from './services/mcp-servers'
 import { McpOAuthService } from './services/mcp-oauth'
 import { McpContentService } from './services/mcp-content'
 import { CustomSkillService } from './custom-skills/service'
+import { SkillDeviceLoginService } from './custom-skills/device-login'
 import { SkillRegistryService } from './custom-skills/registry'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
@@ -72,6 +73,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   // 按数据库重建生效目录(缺失或被改动的技能目录重新发布)
   customSkills.reconcile(msg => log.warn({}, msg))
   const skillRegistry = new SkillRegistryService(db, customSkills, adminAudit)
+  const skillDeviceLogins = new SkillDeviceLoginService(db, skillRegistry, adminAudit)
   const fileManager = new FileManagerService(db, () => settings.get(), () => knowledge.list(), files, adminAudit)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
@@ -110,7 +112,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   mcpUpstream.onElicit = e => elicitation.handle(e)
   const schedules = new ScheduleService({ db, log, settings, sessions, runs, events, teams, bots, profiles })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillRegistry, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, mcpOAuth, mcpContent, customSkills, skillRegistry, skillDeviceLogins, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, mcpTasks, elicitation, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 135 - 0
apps/server/src/custom-skills/device-login.ts

@@ -0,0 +1,135 @@
+// 推送工具的浏览器登录(docs/design/19-skill-cli.md):设备码授权(RFC 8628)。
+// 终端申请 → 管理员在后台核对验证码后批准 → 终端下一次询问时才签发发布密钥(平台不保存明文),登录记录随即作废
+import crypto from 'node:crypto'
+import { and, eq, gte, lt } from 'drizzle-orm'
+import type { RegistryDeviceCodeDto, RegistryDeviceTokenDto, SkillDeviceLoginDto } from '@agent-server/shared'
+import type { DB } from '../db'
+import { skillDeviceLogins } from '../db/schema'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { hashKey } from '../services/catalog'
+import type { SkillRegistryService } from './registry'
+
+type Row = typeof skillDeviceLogins.$inferSelect
+
+/** 登录申请有效期、终端询问间隔、每个 IP 10 分钟内的申请数 */
+export const DEVICE_LOGIN = { ttlMs: 600_000, intervalSec: 5, perIp: 10, pendingMax: 100 }
+
+/** 验证码字母表:去掉元音和易混淆的字符 */
+const ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ'
+
+export class SkillDeviceLoginService {
+  constructor(
+    private db: DB,
+    private registry: SkillRegistryService,
+    private audit: (summary: string, detail?: unknown) => void,
+  ) {}
+
+  /** 终端申请登录:返回设备码(只给终端)与验证码(给人核对) */
+  start(client: { host?: string; user?: string; platform?: string; cliVersion?: string }, ip: string, base: string): RegistryDeviceCodeDto {
+    this.cleanup()
+    const since = new Date(Date.now() - DEVICE_LOGIN.ttlMs).toISOString()
+    if (this.db.select({ id: skillDeviceLogins.id }).from(skillDeviceLogins).where(and(eq(skillDeviceLogins.ip, ip), gte(skillDeviceLogins.createdAt, since))).all().length >= DEVICE_LOGIN.perIp) {
+      throw new AppError(429, 'rate_limited', '登录申请过于频繁,请稍后再试')
+    }
+    if (this.pendingRows().length >= DEVICE_LOGIN.pendingMax) throw new AppError(429, 'rate_limited', '待批准的登录申请过多,请稍后再试')
+    const deviceCode = `sdc_${crypto.randomBytes(32).toString('base64url')}`
+    let userCode = ''
+    do {
+      const b = crypto.randomBytes(8)
+      const c = [...b].map(x => ALPHABET[x % ALPHABET.length]).join('')
+      userCode = `${c.slice(0, 4)}-${c.slice(4)}`
+    } while (this.db.select({ id: skillDeviceLogins.id }).from(skillDeviceLogins).where(eq(skillDeviceLogins.userCode, userCode)).get())
+    const clean = (v: unknown, n: number) => String(v ?? '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, n)
+    this.db.insert(skillDeviceLogins).values({
+      id: newId('sdl'), deviceCodeHash: hashKey(deviceCode), userCode, ip,
+      client: { host: clean(client.host, 100), user: clean(client.user, 60), platform: clean(client.platform, 40), cliVersion: clean(client.cliVersion, 30) },
+      expiresAt: new Date(Date.now() + DEVICE_LOGIN.ttlMs).toISOString(),
+    }).run()
+    const uri = `${base}/admin/skill-login`
+    return { deviceCode, userCode, verificationUri: uri, verificationUriComplete: `${uri}?code=${userCode}`, expiresIn: DEVICE_LOGIN.ttlMs / 1000, interval: DEVICE_LOGIN.intervalSec }
+  }
+
+  /** 终端询问结果:未批准、过于频繁、已拒绝、已过期时报错(错误码同 RFC 8628);批准后签发密钥,只返回这一次 */
+  poll(deviceCode: string): RegistryDeviceTokenDto {
+    const r = this.db.select().from(skillDeviceLogins).where(eq(skillDeviceLogins.deviceCodeHash, hashKey(deviceCode))).get()
+    if (!r) throw new AppError(400, 'invalid_device_code', '登录申请不存在,请重新执行 login')
+    if (r.status === 'consumed' || Date.parse(r.expiresAt) <= Date.now()) throw new AppError(400, 'expired_token', '登录申请已过期,请重新执行 login')
+    if (r.status === 'denied') throw new AppError(400, 'access_denied', '登录申请被拒绝')
+    const now = Date.now()
+    const tooSoon = r.lastPollAt && now - Date.parse(r.lastPollAt) < (DEVICE_LOGIN.intervalSec - 1) * 1000
+    this.db.update(skillDeviceLogins).set({ lastPollAt: new Date(now).toISOString() }).where(eq(skillDeviceLogins.id, r.id)).run()
+    if (tooSoon) throw new AppError(400, 'slow_down', `询问过于频繁,请每 ${DEVICE_LOGIN.intervalSec} 秒询问一次`)
+    if (r.status === 'pending') throw new AppError(400, 'authorization_pending', '等待在浏览器中批准')
+    // approved:签发密钥,登录记录作废(同一设备码不能再换取)
+    const done = this.db.update(skillDeviceLogins).set({ status: 'consumed' }).where(and(eq(skillDeviceLogins.id, r.id), eq(skillDeviceLogins.status, 'approved'))).run()
+    if (done.changes === 0) throw new AppError(400, 'expired_token', '登录申请已过期,请重新执行 login')
+    const { key, publishKey } = this.registry.createKey({ name: r.keyName!, description: `推送工具浏览器登录:${r.client.user}@${r.client.host}(${r.client.platform},来源 IP ${r.ip})`, expiresInDays: r.keyDays }, 'device')
+    this.db.update(skillDeviceLogins).set({ keyId: publishKey.id }).where(eq(skillDeviceLogins.id, r.id)).run()
+    return { token: key, name: publishKey.name, expiresAt: publishKey.expiresAt }
+  }
+
+  // ---- 后台 ----
+
+  pending(): SkillDeviceLoginDto[] {
+    return this.pendingRows().map(r => this.toDto(r))
+  }
+
+  get(userCode: string): SkillDeviceLoginDto {
+    return this.toDto(this.byCode(userCode))
+  }
+
+  approve(userCode: string, input: { name: string; expiresInDays: number | null }): SkillDeviceLoginDto {
+    const r = this.pendingByCode(userCode)
+    const name = input.name.trim()
+    if (!name || name.length > 80) throw badRequest('请填写密钥名称(不超过 80 个字)')
+    const days = input.expiresInDays
+    if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 3650)) throw badRequest('有效期应在 1 ~ 3650 天之间,或选择永久')
+    this.db.update(skillDeviceLogins).set({ status: 'approved', keyName: name, keyDays: days }).where(eq(skillDeviceLogins.id, r.id)).run()
+    this.audit(`批准推送工具登录 ${r.userCode}(${r.client.user}@${r.client.host},来源 IP ${r.ip}),密钥名称 ${name}`)
+    return this.get(userCode)
+  }
+
+  deny(userCode: string): SkillDeviceLoginDto {
+    const r = this.pendingByCode(userCode)
+    this.db.update(skillDeviceLogins).set({ status: 'denied' }).where(eq(skillDeviceLogins.id, r.id)).run()
+    this.audit(`拒绝推送工具登录 ${r.userCode}(${r.client.user}@${r.client.host},来源 IP ${r.ip})`)
+    return this.get(userCode)
+  }
+
+  // ---- 内部 ----
+
+  private pendingRows(): Row[] {
+    return this.db.select().from(skillDeviceLogins).where(and(eq(skillDeviceLogins.status, 'pending'), gte(skillDeviceLogins.expiresAt, nowIso()))).all()
+  }
+
+  private byCode(raw: string): Row {
+    const c = raw.toUpperCase().replace(/[^A-Z]/g, '')
+    const code = c.length === 8 ? `${c.slice(0, 4)}-${c.slice(4)}` : raw
+    const r = this.db.select().from(skillDeviceLogins).where(eq(skillDeviceLogins.userCode, code)).get()
+    if (!r) throw notFound('登录申请(验证码不正确,或申请已过期)')
+    return r
+  }
+
+  private pendingByCode(userCode: string): Row {
+    const r = this.byCode(userCode)
+    if (Date.parse(r.expiresAt) <= Date.now()) throw conflict('expired', '登录申请已过期,请在终端重新执行 login')
+    if (r.status !== 'pending') throw conflict('not_pending', '这个登录申请已经处理过了')
+    return r
+  }
+
+  /** 过期一天以上的登录记录删除 */
+  private cleanup() {
+    this.db.delete(skillDeviceLogins).where(lt(skillDeviceLogins.expiresAt, new Date(Date.now() - 86_400_000).toISOString())).run()
+  }
+
+  private toDto(r: Row): SkillDeviceLoginDto {
+    const expired = Date.parse(r.expiresAt) <= Date.now()
+    return {
+      userCode: r.userCode, client: r.client, ip: r.ip, createdAt: r.createdAt, expiresAt: r.expiresAt,
+      status: r.status === 'pending' && expired ? 'expired' : r.status,
+      defaultName: `${r.client.user || 'user'}@${r.client.host || 'unknown'} · agent-skills`.slice(0, 80),
+      keyName: r.keyName,
+    }
+  }
+}

+ 17 - 6
apps/server/src/custom-skills/registry.ts

@@ -6,7 +6,7 @@ import os from 'node:os'
 import path from 'node:path'
 import { and, desc, eq, gte, inArray } from 'drizzle-orm'
 import type {
-  RegistryItemDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto,
+  RegistryItemDto, RegistryMeDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto,
   SkillSubmissionItemDetailDto, SkillSubmissionItemDto, SkillSubmissionStatus,
 } from '@agent-server/shared'
 import type { DB } from '../db'
@@ -36,7 +36,7 @@ const SECRET_PATTERNS: [RegExp, string][] = [
   [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'],
   [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, '本平台的密钥'],
   [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'],
-  [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '疑似密码或密钥赋值'],
+  [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '密码或密钥赋值'],
 ]
 const URL_RE = /\bhttps?:\/\/([a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?/gi
 
@@ -60,13 +60,13 @@ export class SkillRegistryService {
       if (k && counts.get(k)) counts.get(k)!.pending++
     }
     return this.db.select().from(skillPublishKeys).orderBy(desc(skillPublishKeys.createdAt)).all().map(r => ({
-      id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt,
+      id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt, origin: r.origin,
       createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, lastUsedIp: r.lastUsedIp, ...(counts.get(r.id) ?? { submissions: 0, pending: 0 }),
     }))
   }
 
   /** 返回明文密钥,只在创建时出现一次 */
-  createKey(input: SkillPublishKeyInput): { key: string; publishKey: SkillPublishKeyDto } {
+  createKey(input: SkillPublishKeyInput, origin: 'manual' | 'device' = 'manual'): { key: string; publishKey: SkillPublishKeyDto } {
     const name = input.name?.trim()
     if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字),它表示推送的来源,例如"fwh-app · GitHub Actions"')
     const days = input.expiresInDays
@@ -75,9 +75,9 @@ export class SkillRegistryService {
     const id = newId('spk')
     this.db.insert(skillPublishKeys).values({
       id, name, description: (input.description ?? '').slice(0, 500), keyHash: hashKey(key), keyPrefix: key.slice(0, 12),
-      expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(),
+      expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(), origin,
     }).run()
-    this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…)`)
+    this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…${origin === 'device' ? ',推送工具浏览器登录' : ''})`)
     return { key, publishKey: this.listKeys().find(k => k.id === id)! }
   }
 
@@ -108,6 +108,17 @@ export class SkillRegistryService {
     return r
   }
 
+  /** 推送工具 whoami:当前密钥的信息 */
+  me(key: KeyRow): RegistryMeDto {
+    return { name: key.name, prefix: key.keyPrefix, expiresAt: key.expiresAt, origin: key.origin, createdAt: key.createdAt }
+  }
+
+  /** 推送工具 logout:停用当前密钥 */
+  revokeSelf(key: KeyRow) {
+    this.db.update(skillPublishKeys).set({ enabled: false }).where(eq(skillPublishKeys.id, key.id)).run()
+    this.audit(`推送工具退出登录,停用技能发布密钥 ${key.name}(${key.keyPrefix}…)`)
+  }
+
   // ---- 推送(发布接口) ----
 
   /** 推送一个技能包;dryRun 只校验不保存 */

+ 21 - 0
apps/server/src/db/schema.ts

@@ -291,11 +291,32 @@ export const skillPublishKeys = sqliteTable('skill_publish_keys', {
   keyPrefix: text('key_prefix').notNull(),
   enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
   expiresAt: text('expires_at'),
+  /** manual 后台创建;device 推送工具浏览器登录时签发(docs/design/19-skill-cli.md) */
+  origin: text('origin', { enum: ['manual', 'device'] }).notNull().default('manual'),
   createdAt: createdAt(),
   lastUsedAt: text('last_used_at'),
   lastUsedIp: text('last_used_ip'),
 })
 
+/** 推送工具的浏览器登录(设备码授权,RFC 8628):批准后在终端下一次询问时签发发布密钥 */
+export const skillDeviceLogins = sqliteTable('skill_device_logins', {
+  id: text('id').primaryKey(),
+  /** 设备码的哈希(设备码只交给终端) */
+  deviceCodeHash: text('device_code_hash').notNull().unique(),
+  /** 给人核对的验证码,如 WDJB-MJHT */
+  userCode: text('user_code').notNull().unique(),
+  client: text('client', { mode: 'json' }).$type<{ host: string; user: string; platform: string; cliVersion: string }>().notNull(),
+  ip: text('ip').notNull(),
+  status: text('status', { enum: ['pending', 'approved', 'denied', 'consumed'] }).notNull().default('pending'),
+  /** 批准时确定的密钥名称与有效期 */
+  keyName: text('key_name'),
+  keyDays: integer('key_days'),
+  keyId: text('key_id'),
+  expiresAt: text('expires_at').notNull(),
+  lastPollAt: text('last_poll_at'),
+  createdAt: createdAt(),
+})
+
 /** 一次推送 */
 export const skillSubmissions = sqliteTable('skill_submissions', {
   id: text('id').primaryKey(),

+ 140 - 0
apps/server/src/http/npm-registry.ts

@@ -0,0 +1,140 @@
+// SDK 包与推送工具的 npm 仓库(只读,公开、不含密钥):/npm/ 下按 npm 仓库协议提供 scripts/build-sdk.mjs 生成的包,
+// 业务平台与项目用 @agent-server:registry=<服务器>/npm/ 安装,npx …@latest 取得最新版本。
+// 包目录中的 <名称>-<x.y.z>.tgz 即全部版本(部署时保留旧版本);包信息读取 tgz 中的 package/package.json,按文件修改时间缓存
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import zlib from 'node:zlib'
+import type { FastifyInstance } from 'fastify'
+
+const TARBALL = /^[\w.-]+-\d+\.\d+\.\d+\.tgz$/
+
+interface Tarball {
+  manifest: Record<string, unknown> & { name: string; version: string }
+  integrity: string
+  shasum: string
+}
+
+/** tar 中某个文件的内容(ustar:512 字节头,名称 0–100,八进制大小 124–136) */
+function tarEntry(tar: Buffer, name: string): Buffer | null {
+  for (let off = 0; off + 512 <= tar.length;) {
+    const header = tar.subarray(off, off + 512)
+    const entry = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '')
+    if (!entry) return null
+    const size = parseInt(header.subarray(124, 136).toString('utf8').replace(/\0.*$/s, '').trim() || '0', 8)
+    if (entry === name) return tar.subarray(off + 512, off + 512 + size)
+    off += 512 + Math.ceil(size / 512) * 512
+  }
+  return null
+}
+
+export function compareVersions(a: string, b: string): number {
+  const pa = a.split('.').map(Number)
+  const pb = b.split('.').map(Number)
+  for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return (pa[i] ?? 0) - (pb[i] ?? 0)
+  return 0
+}
+
+export class NpmRegistry {
+  private cache = new Map<string, { mtimeMs: number; t: Tarball | null }>()
+
+  constructor(private dir: () => string | undefined) {}
+
+  private read(file: string): Tarball | null {
+    const dir = this.dir()
+    if (!dir) return null
+    const p = path.join(dir, file)
+    const mtimeMs = fs.statSync(p).mtimeMs
+    const hit = this.cache.get(file)
+    if (hit?.mtimeMs === mtimeMs) return hit.t
+    let t: Tarball | null = null
+    try {
+      const buf = fs.readFileSync(p)
+      const pkg = tarEntry(zlib.gunzipSync(buf), 'package/package.json')
+      const manifest = pkg && JSON.parse(pkg.toString('utf8'))
+      if (manifest?.name && manifest.version) {
+        t = {
+          manifest,
+          integrity: `sha512-${crypto.createHash('sha512').update(buf).digest('base64')}`,
+          shasum: crypto.createHash('sha1').update(buf).digest('hex'),
+        }
+      }
+    } catch {
+      // 不是有效的包,忽略
+    }
+    this.cache.set(file, { mtimeMs, t })
+    return t
+  }
+
+  /** 全部包:文件名 → 包信息 */
+  private all(): [string, Tarball][] {
+    const dir = this.dir()
+    if (!dir) return []
+    return fs.readdirSync(dir).filter(f => TARBALL.test(f)).map(f => [f, this.read(f)] as [string, Tarball | null])
+      .filter((x): x is [string, Tarball] => !!x[1])
+  }
+
+  /** 包信息(npm 仓库协议的 packument);没有这个包时为 null */
+  packument(name: string, base: string) {
+    const versions: Record<string, unknown> = {}
+    let latest: string | null = null
+    for (const [file, t] of this.all()) {
+      if (t.manifest.name !== name) continue
+      versions[t.manifest.version] = {
+        ...t.manifest, _id: `${name}@${t.manifest.version}`,
+        dist: { tarball: `${base}/npm/-/${file}`, integrity: t.integrity, shasum: t.shasum },
+      }
+      if (!latest || compareVersions(t.manifest.version, latest) > 0) latest = t.manifest.version
+    }
+    return latest ? { _id: name, name, 'dist-tags': { latest }, versions } : null
+  }
+
+  /** 每个包的最新版本(后台显示安装命令) */
+  latest(): { name: string; version: string; bin: boolean }[] {
+    const m = new Map<string, Tarball>()
+    for (const [, t] of this.all()) {
+      const cur = m.get(t.manifest.name)
+      if (!cur || compareVersions(t.manifest.version, cur.manifest.version) > 0) m.set(t.manifest.name, t)
+    }
+    return [...m.values()].map(t => ({ name: t.manifest.name, version: t.manifest.version, bin: !!t.manifest.bin }))
+  }
+
+  /** 包文件的路径;不存在时为 null */
+  file(file: string): string | null {
+    const dir = this.dir()
+    return dir && TARBALL.test(file) && fs.existsSync(path.join(dir, file)) ? path.join(dir, file) : null
+  }
+}
+
+export function registerNpmRegistry(f: FastifyInstance, registry: NpmRegistry) {
+  const notFound = { error: { code: 'not_found', message: '包不存在' } }
+  // 包文件:内容按版本固定
+  f.get<{ Params: { file: string } }>('/npm/-/:file', (req, reply) => {
+    const p = registry.file(req.params.file)
+    if (!p) return reply.code(404).send(notFound)
+    reply.header('Content-Type', 'application/octet-stream')
+    reply.header('Cache-Control', 'public, max-age=31536000, immutable')
+    return reply.send(fs.createReadStream(p))
+  })
+  // 各包的最新版本(本服务自用,不属于 npm 协议)
+  f.get('/npm/-/latest', (_req, reply) => {
+    reply.header('Cache-Control', 'no-cache')
+    return registry.latest()
+  })
+  // npm install 时的安全审计:本仓库没有漏洞数据,返回空结果(否则 npm 会报错)
+  f.post('/npm/-/npm/v1/security/*', (_req, reply) => reply.send({}))
+  // 包信息:npm 把作用域包的 / 编码为 %2f(/npm/@agent-server%2fclient),也接受不编码的写法
+  f.get('/npm/*', (req, reply) => {
+    const raw = (req.params as { '*': string })['*']
+    let name: string
+    try {
+      name = decodeURIComponent(raw)
+    } catch {
+      return reply.code(404).send(notFound)
+    }
+    const doc = registry.packument(name, `${req.protocol}://${req.host}`)
+    if (!doc) return reply.code(404).send(notFound)
+    reply.header('Cache-Control', 'no-cache')
+    return doc
+  })
+}

+ 6 - 0
apps/server/src/http/routes/admin.ts

@@ -332,6 +332,12 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.skillRegistry.deleteKey(req.params.id)
       reply.code(204)
     })
+    // 推送工具的浏览器登录(docs/design/19-skill-cli.md)
+    g.get('/skill-logins', async () => app.skillDeviceLogins.pending())
+    g.get<{ Params: { code: string } }>('/skill-logins/:code', async req => app.skillDeviceLogins.get(req.params.code))
+    g.post<{ Params: { code: string } }>('/skill-logins/:code/approve', async req =>
+      app.skillDeviceLogins.approve(req.params.code, parse(z.object({ name: z.string(), expiresInDays: z.number().int().nullable().default(365) }), req.body)))
+    g.post<{ Params: { code: string } }>('/skill-logins/:code/deny', async req => app.skillDeviceLogins.deny(req.params.code))
     g.get('/skill-submissions/count', async () => ({ pending: app.skillRegistry.pendingCount() }))
     g.get<{ Querystring: { status?: string } }>('/skill-submissions', async req => app.skillRegistry.listItems(req.query.status === 'done' ? 'done' : 'pending'))
     g.get<SkillParams>('/skill-submissions/:id', async req => app.skillRegistry.item(req.params.id))

+ 18 - 0
apps/server/src/http/routes/registry.ts

@@ -21,6 +21,24 @@ export default async function registryRoutes(f: FastifyInstance, { app }: { app:
     return key
   }
 
+  // 浏览器登录(设备码授权,docs/design/19-skill-cli.md):这两个接口不需要密钥
+  f.post('/device/code', async (req, reply) => {
+    const b = (req.body ?? {}) as Record<string, unknown>
+    reply.code(201)
+    return app.skillDeviceLogins.start({ host: b.host as string, user: b.user as string, platform: b.platform as string, cliVersion: b.cliVersion as string }, req.ip, `${req.protocol}://${req.host}`)
+  })
+  f.post('/device/token', async req => {
+    const code = (req.body as { deviceCode?: unknown } | undefined)?.deviceCode
+    if (typeof code !== 'string' || !code) throw badRequest('缺少 deviceCode')
+    return app.skillDeviceLogins.poll(code)
+  })
+
+  f.get('/me', async req => app.skillRegistry.me(keyOf(req)))
+  f.delete('/me', async (req, reply) => {
+    app.skillRegistry.revokeSelf(keyOf(req))
+    reply.code(204)
+  })
+
   f.post<{ Querystring: { dryRun?: string } }>('/submissions', async (req, reply) => {
     const key = keyOf(req)
     if (!req.isMultipart()) throw badRequest('请用 multipart/form-data 上传:file(zip)、message、meta(JSON)')

+ 4 - 13
apps/server/src/http/server.ts

@@ -14,6 +14,7 @@ import v1Routes from './routes/v1'
 import botRoutes from '../teams/routes'
 import { registerGateway } from '../mcp-gateway/server'
 import { registerMobileApp } from './mobile-app'
+import { NpmRegistry, registerNpmRegistry } from './npm-registry'
 import registryRoutes from './routes/registry'
 
 export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
@@ -72,19 +73,9 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
     // f.get('/', (_req, reply) => reply.redirect('/admin/'))
   }
 
-  // 业务平台使用的 SDK 包(scripts/build-sdk.mjs 生成,公开、不含密钥):生产在 app/sdk,开发在仓库的 dist-sdk
-  const sdkDist = [config.sdkDist, adminDist && path.resolve(adminDist, '../sdk'), path.resolve(here, '../../../../dist-sdk'), path.resolve(here, '../../../dist-sdk')]
-    .find((p): p is string => !!p && fs.existsSync(path.join(p, 'index.json')))
-  if (sdkDist) {
-    // 每次请求读取目录,重新部署 SDK 后无需重启
-    f.get<{ Params: { file: string } }>('/sdk/:file', (req, reply) => {
-      const file = req.params.file
-      if (!/^[\w.-]+\.(tgz|json)$/.test(file) || !fs.existsSync(path.join(sdkDist, file))) return reply.code(404).send({ error: { code: 'not_found', message: '文件不存在' } })
-      reply.header('Content-Type', file.endsWith('.json') ? 'application/json' : 'application/gzip')
-      reply.header('Cache-Control', file.endsWith('.json') ? 'no-cache' : 'public, max-age=31536000, immutable')
-      return reply.send(fs.createReadStream(path.join(sdkDist, file)))
-    })
-  }
+  // SDK 包与推送工具的 npm 仓库(scripts/build-sdk.mjs 生成,公开、不含密钥):生产在 app/sdk,开发在仓库的 dist-sdk。每次请求读取目录,重新部署后无需重启
+  const sdkDirs = [config.sdkDist, adminDist && path.resolve(adminDist, '../sdk'), path.resolve(here, '../../../../dist-sdk'), path.resolve(here, '../../../dist-sdk')]
+  registerNpmRegistry(f, new NpmRegistry(() => sdkDirs.find((p): p is string => !!p && fs.existsSync(p))))
 
   // Android App 下载页(deploy/release-apk.sh 发布):生产在 app/mobile,开发在仓库的 dist-mobile;目录不存在时页面提示还没有发布
   const mobileDirs = [config.mobileDist, adminDist && path.resolve(adminDist, '../mobile'), path.resolve(here, '../../../../dist-mobile'), path.resolve(here, '../../../dist-mobile')]

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi' | 'sdl'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 65 - 0
apps/server/test/npm-registry.test.ts

@@ -0,0 +1,65 @@
+// SDK 包与推送工具的 npm 仓库(/npm/):包信息、最新版本、包文件、作用域名称的两种写法、安全审计
+import { execFileSync } from 'node:child_process'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import Fastify from 'fastify'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { NpmRegistry, registerNpmRegistry } from '../src/http/npm-registry'
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-registry-'))
+const f = Fastify()
+
+function pack(manifest: Record<string, unknown>, file: string) {
+  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-registry-stage-'))
+  fs.mkdirSync(path.join(stage, 'package'))
+  fs.writeFileSync(path.join(stage, 'package', 'package.json'), JSON.stringify(manifest))
+  fs.writeFileSync(path.join(stage, 'package', 'index.js'), 'export {}\n')
+  execFileSync('tar', ['czf', path.join(dir, file), 'package'], { cwd: stage })
+}
+
+beforeAll(async () => {
+  pack({ name: '@agent-server/client', version: '0.5.0' }, 'agent-server-client-0.5.0.tgz')
+  pack({ name: '@agent-server/client', version: '0.10.0', peerDependencies: { '@agent-server/shared': '0.10.0' } }, 'agent-server-client-0.10.0.tgz')
+  pack({ name: '@agent-server/skills-cli', version: '0.3.0', bin: { 'agent-skills': './agent-skills.mjs' } }, 'agent-server-skills-cli-0.3.0.tgz')
+  fs.copyFileSync(path.join(dir, 'agent-server-skills-cli-0.3.0.tgz'), path.join(dir, 'agent-skills.tgz')) // 不带版本号的文件不算
+  fs.writeFileSync(path.join(dir, 'broken-1.0.0.tgz'), 'not a tarball')
+  registerNpmRegistry(f, new NpmRegistry(() => dir))
+  await f.ready()
+})
+afterAll(() => f.close())
+
+describe('npm 仓库', () => {
+  it('包信息:全部版本、最新版本按数字比较、对等依赖与 bin 原样保留、校验值与文件一致', async () => {
+    for (const url of ['/npm/@agent-server%2fclient', '/npm/@agent-server%2Fclient', '/npm/@agent-server/client']) {
+      const r = await f.inject({ url, headers: { host: 'agent.example.com' } })
+      expect(r.statusCode).toBe(200)
+      expect(r.headers['cache-control']).toBe('no-cache')
+      const doc = r.json()
+      expect(doc['dist-tags']).toEqual({ latest: '0.10.0' })
+      expect(Object.keys(doc.versions).sort()).toEqual(['0.10.0', '0.5.0'])
+      expect(doc.versions['0.10.0'].peerDependencies).toEqual({ '@agent-server/shared': '0.10.0' })
+      expect(doc.versions['0.10.0'].dist.tarball).toBe('http://agent.example.com/npm/-/agent-server-client-0.10.0.tgz')
+    }
+    const cli = (await f.inject({ url: '/npm/@agent-server%2fskills-cli' })).json()
+    expect(Object.keys(cli.versions)).toEqual(['0.3.0'])
+    expect(cli.versions['0.3.0'].bin).toEqual({ 'agent-skills': './agent-skills.mjs' })
+
+    const tgz = await f.inject({ url: '/npm/-/agent-server-skills-cli-0.3.0.tgz' })
+    expect(tgz.statusCode).toBe(200)
+    expect(tgz.headers['cache-control']).toContain('immutable')
+    expect(cli.versions['0.3.0'].dist.integrity).toBe(`sha512-${crypto.createHash('sha512').update(tgz.rawPayload).digest('base64')}`)
+  })
+
+  it('不存在的包与文件为 404;旧的固定文件名不提供;安全审计返回空结果;最新版本列表', async () => {
+    expect((await f.inject({ url: '/npm/@agent-server%2fnope' })).statusCode).toBe(404)
+    expect((await f.inject({ url: '/npm/-/agent-skills.tgz' })).statusCode).toBe(404)
+    expect((await f.inject({ url: '/npm/-/..%2f..%2fetc%2fpasswd' })).statusCode).toBe(404)
+    expect((await f.inject({ method: 'POST', url: '/npm/-/npm/v1/security/advisories/bulk', payload: {} })).json()).toEqual({})
+    expect((await f.inject({ url: '/npm/-/latest' })).json()).toEqual(expect.arrayContaining([
+      { name: '@agent-server/client', version: '0.10.0', bin: false },
+      { name: '@agent-server/skills-cli', version: '0.3.0', bin: true },
+    ]))
+  })
+})

+ 263 - 0
apps/server/test/skills-cli.test.ts

@@ -0,0 +1,263 @@
+// 技能推送工具 agent-skills(docs/design/19-skill-cli.md):对测试服务端到端运行命令;浏览器登录(设备码授权)的服务端规则
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { main, type Io } from '../../../packages/skills-cli/src/main'
+import { isNewer, npxArgs } from '../../../packages/skills-cli/src/update'
+import { createApp, type App } from '../src/app'
+import { DEVICE_LOGIN } from '../src/custom-skills/device-login'
+import { buildServer } from '../src/http/server'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let cfgDir: string
+let project: string
+
+const md = (name: string, extra = '') => `---\nname: ${name}\ndescription: ${name} 的说明:处理某类业务数据的流程与规范\n${extra}---\n# ${name}\n`
+
+/** 运行一条命令;onSleep 在每次等待时调用(模拟管理员在浏览器中操作) */
+async function run(args: string[], opts: { env?: Record<string, string>; cwd?: string; onSleep?: () => void; stdin?: string; version?: string; relaunch?: Io['relaunch'] } = {}) {
+  const out: string[] = []
+  const err: string[] = []
+  const io: Io = {
+    out: s => void out.push(s), err: s => void err.push(s),
+    env: { AGENT_SKILLS_CONFIG_DIR: cfgDir, ...opts.env }, cwd: opts.cwd ?? project, tty: false,
+    openUrl: async () => false,
+    sleep: async () => {
+      opts.onSleep?.()
+      await new Promise(r => setTimeout(r, 20))
+    },
+    readStdin: async () => opts.stdin ?? '',
+    prompt: async () => '',
+    relaunch: opts.relaunch ?? (async () => null),
+    version: opts.version,
+  }
+  const code = await main(args, io)
+  return { code, out: out.join('\n'), err: err.join('\n') }
+}
+const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-cfg-'))
+  project = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-proj-'))
+  for (const n of ['alpha', 'beta']) {
+    fs.mkdirSync(path.join(project, 'skills', n, 'references'), { recursive: true })
+    fs.writeFileSync(path.join(project, 'skills', n, 'SKILL.md'), md(n))
+    fs.writeFileSync(path.join(project, 'skills', n, 'references', 'guide.md'), `${n} v1\n`)
+  }
+  fs.writeFileSync(path.join(project, 'skills', 'alpha', '.env'), 'TOKEN=abc')
+  DEVICE_LOGIN.intervalSec = 1
+})
+afterAll(async () => {
+  await server.close()
+})
+
+describe('推送工具:浏览器登录', () => {
+  it('login:显示验证码,管理员批准后保存密钥(权限 600);whoami', async () => {
+    let approved = false
+    const r = await run(['login', '--server', base], {
+      onSleep: () => {
+        const l = app.skillDeviceLogins.pending()[0]
+        if (l && !approved) {
+          expect(l.client.user).toBe(os.userInfo().username)
+          app.skillDeviceLogins.approve(l.userCode, { name: '张三 · 本机', expiresInDays: 30 })
+          approved = true
+        }
+      },
+    })
+    expect(r.code).toBe(0)
+    expect(r.err).toMatch(/验证码:[A-Z]{4}-[A-Z]{4}/)
+    expect(r.err).toContain(`${base}/admin/skill-login?code=`)
+    expect(r.out).toContain('已登录')
+    const file = path.join(cfgDir, 'credentials.json')
+    expect(fs.statSync(file).mode & 0o777).toBe(0o600)
+    const creds = JSON.parse(fs.readFileSync(file, 'utf8'))
+    expect(creds.default).toBe(base)
+    expect(creds.servers[base].token).toMatch(/^sk_pub_/)
+    expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')).toMatchObject({ origin: 'device' })
+
+    const who = await run(['whoami'])
+    expect(who.code).toBe(0)
+    expect(who.out).toContain('张三 · 本机')
+    expect(who.out).toContain('浏览器登录')
+  })
+
+  it('服务端规则:拒绝、询问过快、设备码只能换取一次、每个 IP 的申请次数', () => {
+    const d = app.skillDeviceLogins.start({ host: 'h', user: 'u' }, '10.0.0.1', 'https://x')
+    expect(d.verificationUriComplete).toBe(`https://x/admin/skill-login?code=${d.userCode}`)
+    expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/等待/)
+    app.skillDeviceLogins.deny(d.userCode.toLowerCase().replace('-', ' '))
+    expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/拒绝/)
+    expect(() => app.skillDeviceLogins.approve(d.userCode, { name: 'x', expiresInDays: 1 })).toThrow(/处理过/)
+
+    const saved = DEVICE_LOGIN.intervalSec
+    DEVICE_LOGIN.intervalSec = 5
+    try {
+      const e = app.skillDeviceLogins.start({}, '10.0.0.2', 'https://x')
+      expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/等待/)
+      expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/过于频繁/)
+    } finally {
+      DEVICE_LOGIN.intervalSec = saved
+    }
+
+    const f = app.skillDeviceLogins.start({}, '10.0.0.3', 'https://x')
+    app.skillDeviceLogins.approve(f.userCode, { name: '一次性', expiresInDays: null })
+    expect(app.skillDeviceLogins.poll(f.deviceCode)).toMatchObject({ name: '一次性', expiresAt: null })
+    expect(() => app.skillDeviceLogins.poll(f.deviceCode)).toThrow(/过期/)
+    expect(app.skillDeviceLogins.get(f.userCode).status).toBe('consumed')
+
+    for (let i = 0; i < DEVICE_LOGIN.perIp; i++) app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')
+    expect(() => app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')).toThrow(/频繁/)
+  })
+})
+
+describe('推送工具:推送与查询', () => {
+  it('init 生成不含密钥的项目配置;push --dry-run 只校验;本地预检不通过时不推送', async () => {
+    expect((await run(['init'])).code).toBe(0)
+    const cfg = JSON.parse(fs.readFileSync(path.join(project, '.agent-skills.json'), 'utf8'))
+    expect(cfg).toEqual({ server: base, paths: ['skills'], exclude: [] })
+    expect(JSON.stringify(cfg)).not.toContain('sk_pub_')
+
+    const dry = await run(['push', '--dry-run'])
+    expect(dry.code).toBe(0)
+    expect(dry.out).toContain('试运行')
+    expect(dry.out).toContain('.env(已排除)')
+    expect(app.skillRegistry.pendingCount()).toBe(0)
+
+    fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\n---\n')
+    const bad = await run(['push'])
+    expect(bad.code).toBe(1)
+    expect(bad.out).toContain('缺少 description')
+    expect(app.skillRegistry.pendingCount()).toBe(0)
+    fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), md('beta'))
+  })
+
+  it('push:在子目录中执行也按项目配置找技能;输出审核链接;status 查看结果', async () => {
+    const r = await run(['push', '-m', '首次推送'], { cwd: path.join(project, 'skills', 'alpha') })
+    expect(r.code).toBe(0)
+    expect(r.out).toContain('已推送 sub_')
+    expect(r.out).toMatch(/alpha {2}待审核(新技能)/)
+    const item = pending('alpha')!
+    expect(r.out).toContain(`${base}/admin/plugins?tab=custom&review=${item.id}`)
+    expect(item).toMatchObject({ message: '首次推送', keyName: '张三 · 本机' })
+    expect(item.meta.cli).toBe('dev')
+    expect(app.skillRegistry.item(item.id).files.map(f => f.path)).toEqual(['SKILL.md', 'references/guide.md'])
+
+    const st = await run(['status'])
+    expect(st.out).toMatch(/alpha(待审核)、beta(待审核)/)
+  })
+
+  it('push --wait:批准后退出码 0;被拒绝为 4;超时仍在待审核为 5', async () => {
+    fs.writeFileSync(path.join(project, 'skills', 'alpha', 'references', 'guide.md'), 'alpha v2\n')
+    const ok = await run(['push', '--only', 'alpha', '--wait'], {
+      onSleep: () => {
+        const i = pending('alpha')
+        if (i) app.skillRegistry.approve(i.id, { action: 'create', name: 'alpha', publish: true })
+      },
+    })
+    expect(ok.code).toBe(0)
+    expect(ok.out).toContain('审核结果')
+    expect(ok.out).toContain('alpha  已批准:新建 alpha v1,已发布')
+
+    const rejected = await run(['push', '--only', 'beta', '--wait'], {
+      onSleep: () => {
+        const i = pending('beta')
+        if (i) app.skillRegistry.reject(i.id, '描述不清楚')
+      },
+    })
+    expect(rejected.code).toBe(4)
+    expect(rejected.out).toContain('拒绝原因:描述不清楚')
+
+    const timeout = await run(['push', '--only', 'beta', '--wait', '--timeout', '0.2'])
+    expect(timeout.code).toBe(5)
+    expect(timeout.out).toContain('仍在待审核')
+
+    // 撤回
+    const id = /已推送 (sub_\w+)/.exec(timeout.out)![1]!
+    const w = await run(['withdraw', id])
+    expect(w.code).toBe(0)
+    expect(w.out).toContain('beta  已撤回')
+  })
+
+  it('CI:用环境变量中的密钥推送;--strict 时疑似密钥为错误;无效密钥退出码 2', async () => {
+    const { key } = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 })
+    const ci = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-ci-'))
+    fs.mkdirSync(path.join(ci, 'gamma'))
+    fs.writeFileSync(path.join(ci, 'gamma', 'SKILL.md'), md('gamma'))
+    fs.writeFileSync(path.join(ci, 'gamma', 'run.sh'), 'curl -u admin:password123@ https://x\npassword = abcdefghijklmnop1234\n')
+    const env = { AGENT_SKILLS_TOKEN: key, AGENT_SKILLS_SERVER: base, AGENT_SKILLS_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-empty-')) }
+
+    const strict = await run(['push', '--strict'], { cwd: ci, env })
+    expect(strict.code).toBe(1)
+    expect(strict.out).toContain('run.sh 可能包含密码或密钥赋值')
+
+    const r = await run(['push', '--json'], { cwd: ci, env })
+    expect(r.code).toBe(0)
+    const sub = JSON.parse(r.out)
+    expect(sub.items[0]).toMatchObject({ name: 'gamma', status: 'pending' })
+    expect(pending('gamma')!.keyName).toBe('fwh-app · CI')
+
+    const bad = await run(['push'], { cwd: ci, env: { ...env, AGENT_SKILLS_TOKEN: 'sk_pub_wrong' } })
+    expect(bad.code).toBe(2)
+    expect(bad.err).toContain('无效')
+  })
+
+  it('logout:在服务器上停用密钥并删除本机保存的密钥', async () => {
+    const r = await run(['logout'])
+    expect(r.code).toBe(0)
+    expect(r.out).toContain('已在服务器上停用密钥:张三 · 本机')
+    expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')!.enabled).toBe(false)
+    expect(JSON.parse(fs.readFileSync(path.join(cfgDir, 'credentials.json'), 'utf8')).servers).toEqual({})
+    expect((await run(['whoami'])).code).toBe(2)
+    expect((await run(['bogus'])).code).toBe(1)
+    expect((await run(['--help'])).out).toContain('退出码')
+  })
+})
+
+describe('推送工具:自动更新', () => {
+  it('服务器上有新版本时用带版本号的包重新执行同一条命令;版本相同、关闭检查或查询失败时继续用当前版本', async () => {
+    expect(isNewer('0.10.0', '0.9.9')).toBe(true)
+    expect(isNewer('0.2.0', '0.2.0')).toBe(false)
+    expect(isNewer('1.0.0', 'dev')).toBe(false)
+
+    const real = globalThis.fetch
+    let latest: string | null = '0.3.0'
+    const spy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
+      if (!String(input).endsWith('/npm/@agent-server%2fskills-cli')) return real(input, init)
+      return Promise.resolve(latest ? Response.json({ name: '@agent-server/skills-cli', 'dist-tags': { latest } }) : new Response('', { status: 404 }))
+    })
+    try {
+      const calls: string[][] = []
+      const relaunch: Io['relaunch'] = async (release, argv) => {
+        calls.push(npxArgs(release, argv))
+        return 7
+      }
+      const up = await run(['whoami', '--server', base], { version: '0.2.0', relaunch })
+      expect(up.code).toBe(7)
+      expect(up.err).toContain('新版本 0.3.0(当前 0.2.0)')
+      expect(calls).toEqual([['-y', '--registry', `${base}/npm/`, '@agent-server/skills-cli@0.3.0', 'whoami', '--server', base]])
+
+      // 无法重新执行时继续用当前版本(此时没有登录,whoami 退出码 2)
+      const failed = await run(['whoami', '--server', base], { version: '0.2.0', relaunch: async () => null })
+      expect(failed.code).toBe(2)
+      expect(failed.err).toContain('无法自动更新')
+
+      calls.length = 0
+      expect((await run(['whoami', '--server', base], { version: '0.3.0', relaunch })).code).toBe(2)
+      expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch, env: { AGENT_SKILLS_NO_UPDATE_CHECK: '1' } })).code).toBe(2)
+      expect((await run(['whoami', '--server', base], { relaunch })).code).toBe(2) // 开发版本
+      latest = null
+      expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch })).code).toBe(2)
+      expect(calls).toEqual([])
+    } finally {
+      spy.mockRestore()
+    }
+  })
+})

+ 2 - 1
deploy/deploy.sh

@@ -15,9 +15,10 @@ pnpm --filter @agent-server/server deploy --prod "$STAGE/server" >/dev/null
 
 sudo rsync -a --delete "$STAGE/server/" "$ROOT/app/server/"
 sudo rsync -a --delete apps/admin/dist/ "$ROOT/app/admin/"
-# SDK 包保留旧版本(业务平台可能锁定在旧版本),只更新 index.json 指向最新
+# SDK 包保留旧版本(业务平台可能锁定在旧版本),npm 仓库 /npm/ 按目录中的全部版本提供
 sudo mkdir -p "$ROOT/app/sdk"
 sudo rsync -a dist-sdk/ "$ROOT/app/sdk/"
+sudo rm -f "$ROOT/app/sdk/agent-skills.tgz" "$ROOT/app/sdk/index.json"   # 旧的 /sdk/ 分发方式留下的文件
 sudo rsync -a --delete skills/ "$ROOT/skills/"
 sudo chown -R root:root "$ROOT/app" "$ROOT/skills"
 sudo chmod -R a+rX "$ROOT/app" "$ROOT/skills"

+ 3 - 1
docs/README.md

@@ -30,7 +30,8 @@ docs/
 │  ├─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
 │  ├─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
 │  ├─ 17-run-script.md        脚本调用工具:模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
-│  └─ 18-skill-registry.md    技能发布平台:项目推送技能,管理员审核后新建或覆盖
+│  ├─ 18-skill-registry.md    技能发布平台:项目推送技能,管理员审核后新建或覆盖
+│  └─ 19-skill-cli.md         技能推送工具 agent-skills:浏览器登录、预检打包推送、等待审核
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -66,6 +67,7 @@ docs/
 | [0025](adr/0025-mcp-spec-features.md) | 按 MCP 规范补齐工具以外的能力(长任务、询问用户、OAuth、资源等) | Accepted |
 | [0026](adr/0026-run-script-in-gateway.md) | 脚本批量调用工具由网关在沙箱中执行 | Accepted |
 | [0027](adr/0027-skill-registry-push-and-review.md) | 自定义技能改为推送 + 审核,去掉技能源与上传 zip | Accepted |
+| [0028](adr/0028-skill-cli-device-login.md) | 技能推送工具:Node 命令行 + 设备码浏览器登录 | Accepted |
 
 ## 调研
 

+ 1 - 1
docs/adr/0019-browser-tokens-and-sdk-distribution.md

@@ -21,7 +21,7 @@
   - 不返回 `Access-Control-Allow-Credentials`
   - `/admin/v1`、`/mcp` 不开放
   - SSE 响应在接管连接后同样带上 CORS 头
-- **SDK 分发**:`scripts/build-sdk.mjs` 构建三个包,部署时发布到服务的 `/sdk/`(公开,不含密钥),平台用 URL 安装。
+- **SDK 分发**:`scripts/build-sdk.mjs` 构建三个包,部署时发布到服务的 `/sdk/`(公开,不含密钥),平台用 URL 安装。(2026-11-04 起改为由服务的 npm 仓库 `/npm/` 提供,平台在 `.npmrc` 中登记 `@agent-server:registry`,`/sdk/` 不再提供,见 ADR-0028。)
   - 三个包:`@agent-server/shared`(类型与常量)、`@agent-server/client`、`@agent-server/chat-ui`(ESM + 类型声明 + 编译好的 `style.css`)
   - 包之间是对等依赖(peerDependencies),平台同时安装三个包,pnpm 按包名关联,不访问 npm 仓库
   - 文件名带版本号,内容不可变(长期缓存);旧版本保留,平台可以锁定版本

+ 18 - 0
docs/adr/0028-skill-cli-device-login.md

@@ -0,0 +1,18 @@
+# ADR-0028 技能推送工具:Node 命令行 + 设备码浏览器登录
+
+- 状态:Accepted · 日期:2026-11-03 · 补充 ADR-0027 · 设计见 [19-skill-cli](../design/19-skill-cli.md)
+
+## 背景
+技能发布平台(ADR-0027)需要一个推送工具。推送方是各个项目的开发机(常通过 SSH / VS Code Remote 在远程机器上开发)和 CI;手动复制、保管发布密钥不方便。
+
+## 决策
+- **只提供 Node 命令行工具**:打包成不带依赖的单文件 `@agent-server/skills-cli`,不发布到 npm 官方仓库。开发机与 CI 普遍有 Node;不另做 Python / Shell 版本。
+- **由平台自己的 npm 仓库分发**(2026-11-04 修订):服务在 `/npm/` 提供只读的 npm 仓库协议(包信息 + 包文件,全部版本),用 `npx -y --registry $BASE/npm/ @agent-server/skills-cli@latest` 运行。最初用固定地址的 tgz(`/sdk/agent-skills.tgz`),实测 npx 按地址缓存安装目录,包更新后仍运行旧版本;`@latest` 由 npx 向仓库确认最新版本,是 npm 的标准做法。SDK 的三个包一起迁移,`/sdk/` 不再提供。全局安装或锁定版本时,工具每次执行前查询仓库,有新版本时改用新版本重新执行。
+- **开发机用浏览器登录**:采用 OAuth 设备码授权(RFC 8628)的流程与错误码。浏览器与终端可以不在同一台机器上;管理员核对验证码后批准,终端下一次询问时才签发普通的发布密钥(平台不保存明文),保存在用户目录(权限 600)。CI 继续使用后台创建、存为加密变量的密钥。
+- **配置分层**:命令行参数 > 环境变量 > 项目 `.agent-skills.json`(不含密钥,可提交)> 用户目录的登录信息,密钥不进入项目。
+- **`--wait` 默认关闭**:等待审核适合本机开发;CI 中只推送。超时用单独的退出码 5(仍在待审核),与失败、拒绝(4)区分。
+
+## 后果
+- 本机推送不需要接触密钥明文;登录签发的密钥在后台可见、可停用,`logout` 会在服务器上停用它。
+- 设备码授权存在"诱导管理员批准别人的申请"的风险:审批页显示来源 IP、机器名、系统用户并给出提示;签发的密钥只能产生待审核的提交,影响有限。
+- 推送工具与服务端各自实现一份技能识别与预检规则,修改限额或规则时两边需要同步(测试覆盖了主要路径)。

+ 1 - 1
docs/design/04-api.md

@@ -8,7 +8,7 @@
 - 鉴权:`Authorization: Bearer <api-key>`;每个上层平台一个 Key,不区分使用者
 - 浏览器中使用**浏览器令牌**(`bt_…`,ADR-0019):平台后端用 API Key 调 `POST /v1/browser-tokens { ttlSeconds? }` 换取(默认 1 小时,最长 12 小时),权限与签发它的 Key 相同,但不能再签发令牌;停用或删除 Key 即同时作废
 - 跨域:只对 `/v1` 开放给后台"设置 › 安全 › CORS 来源"中的地址,不携带 Cookie;`/admin/v1` 与 `/mcp` 不开放
-- SDK 包:`GET /sdk/index.json` 列出当前版本,`GET /sdk/<file>.tgz` 下载(公开,不含密钥,见 docs/integration.md)
+- SDK 包与推送工具:只读的 npm 仓库 `/npm/`(`GET /npm/<包名>` 包信息,`GET /npm/-/<file>.tgz` 包文件;`GET /npm/-/latest` 各包最新版本,后台使用)。公开,不含密钥,见 docs/integration.md
 - 格式:JSON;时间为 ISO 8601;ID 为带前缀的 ULID(`ses_`、`run_`、`itx_`、`file_`、`prf_`)
 - 错误:`{ "error": { "code": "session_busy", "message": "...", "details": {} } }`
 

+ 1 - 1
docs/design/18-skill-registry.md

@@ -238,7 +238,7 @@ curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \
 | 阶段 | 内容 |
 | --- | --- |
 | 一期 | 发布密钥、推送接口(含试运行)、自动检查、审核(新建 / 覆盖 / 拒绝)、已处理记录与删除、推送方查询、去掉技能源与上传 zip、审计 |
-| 二期 | 推送工具(命令行,读取环境变量或用户配置)、浏览器登录签发密钥、审核结果通知 |
+| 二期 | 推送工具(命令行,读取环境变量或用户配置)、浏览器登录签发密钥(**已实施,见 [19-skill-cli](19-skill-cli.md)**);审核结果通知 |
 
 ## 12. 实施记录(2026-11-03)
 

+ 133 - 0
docs/design/19-skill-cli.md

@@ -0,0 +1,133 @@
+# 19 技能推送工具 agent-skills
+
+> 状态:**已实施(2026-11-03)**。决策见 ADR-0028。配合技能发布平台([18-skill-registry.md](18-skill-registry.md))使用。
+
+## 1. 目标
+
+让项目(开发机或 CI)用一条命令把技能推送到平台:
+
+- 不用手动打包、不用复制密钥(开发机在浏览器中批准登录);
+- 推送前在本地发现问题(名称、SKILL.md、限额、疑似密钥),而不是等审核时才发现;
+- CI 中按退出码判断结果,可以选择等待审核。
+
+## 2. 形态与分发
+
+- Node 命令行工具(Node 18+),包名 `@agent-server/skills-cli`,源码 `packages/skills-cli`,用 tsup 打包成一个不带依赖的文件(fflate 一起打包,约 50 KB)。
+- 由 `scripts/build-sdk.mjs` 放进 `dist-sdk`,部署到 `app/sdk`(保留旧版本)。服务在 `/npm/` 提供只读的 npm 仓库(`http/npm-registry.ts`,与 SDK 的三个包共用):`GET /npm/<包名>` 返回全部版本与 `dist-tags.latest`,`GET /npm/-/<文件>.tgz` 下载(永久缓存)。不发布到 npm 官方仓库。
+
+```bash
+npx -y --registry https://agent.zenpower.cc/npm/ @agent-server/skills-cli@latest <命令>   # 不安装,每次都是最新版本
+npm i -g --registry https://agent.zenpower.cc/npm/ @agent-server/skills-cli@latest       # 或全局安装,之后用 agent-skills
+```
+
+在项目或用户目录的 `.npmrc` 中加一行 `@agent-server:registry=https://agent.zenpower.cc/npm/` 后可以省略 `--registry`。
+
+### 2.1 更新
+
+- `@latest`:npx 每次向仓库查询包信息(不缓存),有新版本时下载安装,否则直接运行已有的安装。最初用固定地址的 tgz(`/sdk/agent-skills.tgz`),实测 npx 按地址缓存安装目录,包更新后仍运行旧版本,因此改为仓库;`/sdk/` 已不再提供。
+- 全局安装或锁定了版本时:每次执行命令前(`init`、`--help`、`--version` 除外)查询仓库中推送工具的最新版本(超时 3 秒)。比当前版本新时,提示后执行 `npx -y --registry <仓库> @agent-server/skills-cli@<新版本> <原参数>` 并返回它的退出码;子进程设置 `AGENT_SKILLS_NO_UPDATE_CHECK=1`,不会循环。全局安装的另外提示 `npm i -g` 更新命令。
+- 查询失败或 npx 无法执行时继续用当前版本;设置 `AGENT_SKILLS_NO_UPDATE_CHECK=1` 关闭检查。
+
+修改推送工具后必须提高 `packages/skills-cli/package.json` 的版本号:仓库按版本号区分,版本号不变时客户端不会更新。
+
+### 2.2 后台用法页
+
+后台 `/admin/skill-cli`("技能与插件 › 自定义技能"中"推送的技能"标题旁的书本图标、"发布密钥"下方与登录审批页都有入口):本机推送、项目配置、CI 示例、命令、退出码、密钥与更新,命令中的服务器地址取当前地址,可以把页面地址转发给项目方。
+
+## 3. 命令
+
+| 命令 | 说明 |
+| --- | --- |
+| `login [--server 地址] [--no-browser]` | 浏览器登录(§5),密钥保存在用户目录 |
+| `login --with-token` | 从标准输入读取后台创建的密钥,校验后保存 |
+| `logout` | 在服务器上停用当前密钥,并删除本机保存的密钥 |
+| `whoami` | 当前密钥的名称、来源(浏览器登录 / 后台创建)、到期时间、来自环境变量还是本机登录 |
+| `init` | 在当前目录生成 `.agent-skills.json`(服务器地址、技能目录、排除规则;不含密钥) |
+| `push [目录…]` | 推送(§4) |
+| `status [提交ID]` | 最近 10 次提交,或某次提交的审核结果 |
+| `withdraw <提交ID>` | 撤回仍在待审核的技能 |
+
+通用选项:`--server`、`--json`、`-h/--help`、`-v/--version`。
+
+## 4. push
+
+1. **找技能**:参数中的目录,或项目配置的 `paths`(相对配置文件,在子目录中执行也能找到),或当前目录。识别规则与服务端一致:单个技能、`<名称>/SKILL.md`、`skills/<名称>/SKILL.md`。`--only` 只推送指定的技能;同名技能报错。
+2. **本地预检**:技能名规则、SKILL.md 的 name / description、文件数与大小限额(与服务端相同);疑似密钥给出警告,`--strict` 时作为错误。有错误时不推送。
+3. **排除**:跳过符号链接;默认排除 `.git`、`node_modules`、`__pycache__`、`.venv`、`.DS_Store`、`*.pyc`、`.env`、`.env.*`(保留 `.env.example`),项目配置的 `exclude` 可以追加(`*` 不跨目录、`**` 跨目录、`!` 开头表示不排除)。
+4. **打包上传**:在内存中按 `<名称>/<文件>` 打成 zip。提交说明默认取最近一次 git 提交的标题(`-m` 指定);来源信息自动取自 git(仓库地址去掉用户名和密码、分支、提交号、工作区有未提交改动时 `dirty`)与工具版本。
+5. **输出**:每个技能的状态、与平台已有版本相比变化的文件数、需要注意的检查结果、后台审核链接(`/admin/plugins?tab=custom&review=<提交项>`)。`--dry-run` 只校验;`--json` 输出接口返回的 JSON。
+
+### 4.1 --wait
+
+推送后每 5 秒查询一次,直到全部处理或超时(`--timeout` 秒,默认 600)。默认不等待:
+
+- 本机开发推荐 `push --wait`:在浏览器中批准后终端立即显示结果;
+- CI 推荐不等待,只推送:审核依赖人,等待会占用运行器、超时会被当作失败。确实需要等待时,退出码 5 表示"仍在待审核",可以与失败区分。
+
+### 4.2 退出码
+
+| 退出码 | 含义 |
+| --- | --- |
+| 0 | 成功(待审核、无变化;`--wait` 时全部批准或撤回) |
+| 1 | 校验不通过(本地预检、服务端 `invalid`、参数错误) |
+| 2 | 鉴权失败(没有密钥、密钥无效 / 停用 / 过期、登录被拒绝或过期) |
+| 3 | 限额、网络或服务器错误 |
+| 4 | `--wait`:有技能被拒绝 |
+| 5 | `--wait`:超时,仍有技能在待审核 |
+
+## 5. 浏览器登录(设备码授权,RFC 8628)
+
+```
+终端 agent-skills login ──POST /device/code(机器名、系统用户、系统、工具版本)──▶ 平台
+     ◀── 设备码(只给终端)、验证码 WDJB-MJHT、审批页地址、10 分钟有效、询问间隔 5 秒 ──
+显示验证码与地址(有图形界面时自动打开浏览器;SSH 远程终端在任意电脑的浏览器中打开即可)
+                                          管理员打开 /admin/skill-login?code=…(未登录先登录)
+                                          核对验证码、机器、系统用户、来源 IP 后批准(可改密钥名称、有效期)
+终端每 5 秒 POST /device/token ──▶ 未批准:authorization_pending;过快:slow_down(间隔加 5 秒)
+                                   拒绝:access_denied;过期或已用:expired_token
+                                   已批准:此时才签发发布密钥,返回明文一次,申请作废
+保存到用户目录(权限 600)
+```
+
+- 签发的是普通发布密钥(`origin: device`),名称默认"系统用户@机器名 · agent-skills",在后台"发布密钥"中标注"推送工具登录",可以停用、删除。
+- 平台不保存密钥明文:批准时只记录状态,终端下一次询问时才生成。
+- 防冒用:审批页显示来源 IP、机器名、系统用户,并提示只在本人刚执行 login 且验证码一致时批准;即使误批,对方得到的也只是只能产生待审核提交的密钥。
+- 防滥用:每个来源 IP 10 分钟内最多申请 10 次,待批准的申请总数有上限;设备码 32 字节随机、只能换取一次;过期一天以上的申请记录删除。
+- CI 不走浏览器登录,使用后台创建的密钥(环境变量 `AGENT_SKILLS_TOKEN`)。
+
+## 6. 配置
+
+读取顺序:命令行参数 > 环境变量(`AGENT_SKILLS_TOKEN`、`AGENT_SKILLS_SERVER`)> 项目的 `.agent-skills.json`(只含服务器地址、`paths`、`exclude`,从当前目录向上查找)> 用户目录的登录信息。
+
+用户目录:`$XDG_CONFIG_HOME/agent-skills/credentials.json`(默认 `~/.config/agent-skills/`;Windows 为 `%APPDATA%\agent-skills\`;可用 `AGENT_SKILLS_CONFIG_DIR` 指定),按服务器地址分别保存密钥,记录最近一次登录的服务器作为默认值。
+
+## 7. CI 示例
+
+GitHub Actions:
+
+```yaml
+- name: 推送技能
+  if: github.event_name == 'push'
+  run: npx -y --registry https://agent.zenpower.cc/npm/ @agent-server/skills-cli@latest push
+  env:
+    AGENT_SKILLS_TOKEN: ${{ secrets.AGENT_SKILLS_TOKEN }}
+    AGENT_SKILLS_SERVER: https://agent.zenpower.cc
+```
+
+GitLab CI:
+
+```yaml
+push-skills:
+  image: node:22
+  rules: [{ changes: ["skills/**/*"] }]
+  script: npx -y --registry https://agent.zenpower.cc/npm/ @agent-server/skills-cli@latest push
+  variables: { AGENT_SKILLS_SERVER: https://agent.zenpower.cc }   # AGENT_SKILLS_TOKEN 设为受保护的 CI 变量
+```
+
+## 8. 实施记录(2026-11-03)
+
+- 服务端:`custom-skills/device-login.ts`(设备码授权),发布接口增加 `device/code`、`device/token`、`GET / DELETE /me`;后台审批页 `SkillLoginView.vue`(`/admin/skill-login`),"发布密钥"中提示待批准的登录申请;`skill_publish_keys.origin` 区分后台创建与浏览器登录。迁移 `0021_skill_device_login`。
+- 推送工具:`packages/skills-cli`(`main.ts` 命令、`pack.ts` 识别与预检、`config.ts` 配置、`api.ts` 接口、`git.ts` 来源信息)。
+- 后台"API 协议 › 技能发布 API"增加"登录与密钥"一节。
+- 2026-11-04:分发改为平台的 npm 仓库 `/npm/`(`http/npm-registry.ts`,SDK 三个包一起迁移,`/sdk/` 不再提供);自动更新 `update.ts`(版本 0.3.0);后台用法页 `SkillCliView.vue`。
+- 测试:`test/skills-cli.test.ts` 对测试服务端到端运行命令(登录、init、试运行、预检、推送、--wait 的三种结果、撤回、CI 环境变量、--strict、logout)并覆盖设备码授权的服务端规则。

+ 4 - 5
docs/integration.md

@@ -44,12 +44,11 @@ app.post('/api/agent-token', requireLogin, async (req, res) => {
 
 ## 3. 前端:安装 SDK
 
-SDK 由 AgentServer 自己提供,三个包需要一起安装:
+SDK 由 AgentServer 自己的 npm 仓库(`https://agent.zenpower.cc/npm/`)提供。在项目的 `.npmrc` 中登记 `@agent-server` 开头的包从这里安装(其余依赖仍从 npm 官方仓库安装),三个包需要一起安装:
 
 ```bash
-pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.6.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-client-0.6.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.6.0.tgz
+echo "@agent-server:registry=https://agent.zenpower.cc/npm/" >> .npmrc
+pnpm add @agent-server/shared @agent-server/client @agent-server/chat-ui
 ```
 
 | 包 | 内容 |
@@ -58,7 +57,7 @@ pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.6.0.tgz \
 | `@agent-server/client` | `AgentClient`:会话、消息、SSE 订阅(断线续传)、交互回复、上传下载;`BotClient`:机器人工作组(§8) |
 | `@agent-server/chat-ui` | 对话组件 `<AgentChat>` 与状态 `useChatSession`;工作组组件 `<TeamChat>` 与状态 `useTeamSession`(依赖 `vue@^3.5`) |
 
-最新版本号见 `https://agent.zenpower.cc/sdk/index.json`。升级时修改上面 URL 中的版本号即可,旧版本会一直保留。
+升级:`pnpm update "@agent-server/*" --latest`。仓库保留全部旧版本,可以在 package.json 中锁定版本;后台"API Keys"页显示当前版本与安装命令。
 
 ## 4. 前端:使用对话组件
 

+ 2 - 0
examples/platform-demo/.npmrc

@@ -0,0 +1,2 @@
+# @agent-server 开头的 SDK 包从 AgentServer 的 npm 仓库安装(docs/integration.md §3)
+@agent-server:registry=AGENT_SERVER_URL/npm/

+ 1 - 1
examples/platform-demo/README.md

@@ -9,7 +9,7 @@
 
 ```bash
 # 1. 安装(把 AGENT_SERVER_URL 换成实际地址,例如 https://agent.zenpower.cc)
-sed -i 's#AGENT_SERVER_URL#https://agent.zenpower.cc#' package.json && pnpm install
+sed -i 's#AGENT_SERVER_URL#https://agent.zenpower.cc#' .npmrc && pnpm install
 # 2. 在 AgentServer 后台:签发 Agent Key 和 / 或 Bot Key(Bot Key 勾选可用的机器人);设置 › 安全 › CORS 来源中加入本平台的地址
 # 3. 构建并启动
 pnpm build && AGENT_SERVER_URL=https://agent.zenpower.cc AGENT_API_KEY=ask_xxx AGENT_BOT_KEY=bk_xxx pnpm start

+ 3 - 3
examples/platform-demo/package.json

@@ -9,9 +9,9 @@
     "typecheck": "vue-tsc --noEmit"
   },
   "dependencies": {
-    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.6.0.tgz",
-    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.6.0.tgz",
-    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.6.0.tgz",
+    "@agent-server/chat-ui": "^0.6.0",
+    "@agent-server/client": "^0.6.0",
+    "@agent-server/shared": "^0.6.0",
     "vue": "^3.5.44"
   },
   "devDependencies": {

+ 46 - 0
packages/shared/src/api.ts

@@ -747,6 +747,52 @@ export interface SkillPublishKeyDto {
   lastUsedIp: string | null
   submissions: number
   pending: number
+  /** manual 后台创建;device 推送工具浏览器登录时签发 */
+  origin: 'manual' | 'device'
+}
+
+/** 推送工具的浏览器登录申请(后台审批页) */
+export interface SkillDeviceLoginDto {
+  userCode: string
+  client: { host: string; user: string; platform: string; cliVersion: string }
+  ip: string
+  /** expired:未处理且已过期;consumed:已批准且终端已取得密钥 */
+  status: 'pending' | 'approved' | 'denied' | 'consumed' | 'expired'
+  createdAt: string
+  expiresAt: string
+  /** 建议的密钥名称:系统用户@机器名 */
+  defaultName: string
+  keyName: string | null
+}
+
+/** 发布接口:申请浏览器登录 */
+export interface RegistryDeviceCodeDto {
+  /** 只给终端,用于询问结果 */
+  deviceCode: string
+  /** 给人在浏览器中核对,如 WDJB-MJHT */
+  userCode: string
+  verificationUri: string
+  verificationUriComplete: string
+  /** 有效期(秒) */
+  expiresIn: number
+  /** 询问间隔(秒) */
+  interval: number
+}
+
+/** 发布接口:浏览器登录批准后签发的密钥(只返回一次) */
+export interface RegistryDeviceTokenDto {
+  token: string
+  name: string
+  expiresAt: string | null
+}
+
+/** 发布接口:当前密钥 */
+export interface RegistryMeDto {
+  name: string
+  prefix: string
+  expiresAt: string | null
+  origin: 'manual' | 'device'
+  createdAt: string
 }
 
 export interface SkillCheckDto {

+ 22 - 0
packages/skills-cli/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "@agent-server/skills-cli",
+  "version": "0.3.0",
+  "private": true,
+  "type": "module",
+  "description": "AgentServer 技能推送工具(agent-skills)",
+  "bin": { "agent-skills": "./dist/agent-skills.mjs" },
+  "exports": { ".": "./src/main.ts" },
+  "scripts": {
+    "build": "tsup",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "fflate": "^0.8.2"
+  },
+  "devDependencies": {
+    "@agent-server/shared": "workspace:*",
+    "@types/node": "^26.6.5",
+    "tsup": "^9.0.0",
+    "typescript": "^5.9.3"
+  }
+}

+ 52 - 0
packages/skills-cli/src/api.ts

@@ -0,0 +1,52 @@
+// 发布接口的客户端(/skills/registry/v1):错误按退出码归类(401 → 2;429、5xx、网络错误 → 3;其余 → 1)
+import { CliError } from './config'
+
+declare const __CLI_VERSION__: string | undefined
+export const VERSION = typeof __CLI_VERSION__ === 'string' ? __CLI_VERSION__ : 'dev'
+export const USER_AGENT = `agent-skills/${VERSION} (node ${process.version}; ${process.platform}-${process.arch})`
+
+export class ApiError extends CliError {
+  constructor(message: string, code: number, readonly status: number, readonly errorCode: string) {
+    super(message, code)
+  }
+}
+
+export class RegistryClient {
+  constructor(readonly server: string, private token: string | null) {}
+
+  async request<T>(method: string, path: string, opts: { json?: unknown; form?: FormData; auth?: boolean } = {}): Promise<T> {
+    const headers: Record<string, string> = { 'User-Agent': USER_AGENT, Accept: 'application/json' }
+    if (opts.auth !== false) {
+      if (!this.token) throw new CliError('没有可用的发布密钥:请先执行 agent-skills login,或设置环境变量 AGENT_SKILLS_TOKEN', 2)
+      headers.Authorization = `Bearer ${this.token}`
+    }
+    let body: string | FormData | undefined
+    if (opts.form) body = opts.form
+    else if (opts.json !== undefined) {
+      body = JSON.stringify(opts.json)
+      headers['Content-Type'] = 'application/json'
+    }
+    let res: Response
+    try {
+      res = await fetch(`${this.server}/skills/registry/v1${path}`, { method, headers, body })
+    } catch (err) {
+      const cause = (err as { cause?: { code?: string; message?: string } }).cause
+      throw new ApiError(`无法连接 ${this.server}:${cause?.code ?? cause?.message ?? (err as Error).message}`, 3, 0, 'network_error')
+    }
+    if (res.status === 204) return undefined as T
+    const text = await res.text()
+    let data: unknown = null
+    try {
+      data = text ? JSON.parse(text) : null
+    } catch {
+      // 不是 JSON(例如代理返回的错误页)
+    }
+    if (!res.ok) {
+      const e = (data as { error?: { code?: string; message?: string } } | null)?.error
+      const exit = res.status === 401 ? 2 : res.status === 429 || res.status >= 500 || !e ? 3 : 1
+      const hint = res.status === 404 && !e ? '(服务器不支持技能发布接口,请检查地址)' : ''
+      throw new ApiError(e?.message ?? `HTTP ${res.status}${hint}`, exit, res.status, e?.code ?? `http_${res.status}`)
+    }
+    return data as T
+  }
+}

+ 6 - 0
packages/skills-cli/src/bin.ts

@@ -0,0 +1,6 @@
+import { nodeIo, main } from './main'
+
+main(process.argv.slice(2), nodeIo()).then(code => process.exit(code), err => {
+  process.stderr.write(`${(err as Error).stack ?? err}\n`)
+  process.exit(3)
+})

+ 97 - 0
packages/skills-cli/src/config.ts

@@ -0,0 +1,97 @@
+// 配置:命令行参数 > 环境变量(AGENT_SKILLS_TOKEN、AGENT_SKILLS_SERVER)> 项目的 .agent-skills.json(服务器地址、技能目录、排除规则,不含密钥)
+// > 用户目录的登录信息(按服务器地址保存密钥,权限 600)。密钥不会写进项目
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+export const PROJECT_FILE = '.agent-skills.json'
+
+export interface ProjectConfig {
+  /** 服务器地址,如 https://agent.zenpower.cc */
+  server?: string
+  /** 技能所在的目录(相对项目配置文件),默认当前目录 */
+  paths?: string[]
+  /** 额外排除的文件(通配符,如 "*.log"、"drafts/**") */
+  exclude?: string[]
+}
+
+export interface Credentials {
+  /** 最近一次登录的服务器 */
+  default?: string
+  servers: Record<string, { token: string; name: string; expiresAt: string | null; savedAt: string }>
+}
+
+export type Env = Record<string, string | undefined>
+
+/** 用户配置目录:$XDG_CONFIG_HOME/agent-skills,Windows 为 %APPDATA%\agent-skills */
+export function configDir(env: Env): string {
+  if (env.AGENT_SKILLS_CONFIG_DIR) return env.AGENT_SKILLS_CONFIG_DIR
+  if (process.platform === 'win32' && env.APPDATA) return path.join(env.APPDATA, 'agent-skills')
+  return path.join(env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), '.config'), 'agent-skills')
+}
+
+const credentialsFile = (env: Env) => path.join(configDir(env), 'credentials.json')
+
+export function readCredentials(env: Env): Credentials {
+  try {
+    const c = JSON.parse(fs.readFileSync(credentialsFile(env), 'utf8')) as Credentials
+    return { default: c.default, servers: c.servers ?? {} }
+  } catch {
+    return { servers: {} }
+  }
+}
+
+export function writeCredentials(env: Env, c: Credentials) {
+  const file = credentialsFile(env)
+  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
+  fs.writeFileSync(file, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 })
+  fs.chmodSync(file, 0o600)
+}
+
+/** 从当前目录向上找项目配置 */
+export function findProject(cwd: string): { dir: string; config: ProjectConfig } | null {
+  let dir = path.resolve(cwd)
+  for (;;) {
+    const f = path.join(dir, PROJECT_FILE)
+    if (fs.existsSync(f)) {
+      try {
+        return { dir, config: JSON.parse(fs.readFileSync(f, 'utf8')) as ProjectConfig }
+      } catch (err) {
+        throw new CliError(`${f} 不是有效的 JSON:${(err as Error).message}`, 1)
+      }
+    }
+    const up = path.dirname(dir)
+    if (up === dir) return null
+    dir = up
+  }
+}
+
+/** 服务器地址统一为 origin(去掉结尾的 / 与路径) */
+export function normalizeServer(raw: string): string {
+  const s = raw.trim()
+  let u: URL
+  try {
+    u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`)
+  } catch {
+    throw new CliError(`服务器地址不正确:${raw}`, 1)
+  }
+  return u.origin
+}
+
+export function resolveServer(opts: { server?: string }, env: Env, project: ProjectConfig | undefined, creds: Credentials): string | null {
+  const s = opts.server ?? env.AGENT_SKILLS_SERVER ?? project?.server ?? creds.default
+  return s ? normalizeServer(s) : null
+}
+
+export function resolveToken(server: string, env: Env, creds: Credentials): { token: string; from: 'env' | 'login' } | null {
+  if (env.AGENT_SKILLS_TOKEN) return { token: env.AGENT_SKILLS_TOKEN.trim(), from: 'env' }
+  const c = creds.servers[server]
+  return c ? { token: c.token, from: 'login' } : null
+}
+
+/** 带退出码的错误:1 校验不通过;2 鉴权失败;3 限额、网络或服务器错误;4 审核被拒绝;5 等待超时(仍在待审核) */
+export class CliError extends Error {
+  constructor(message: string, readonly code: number) {
+    super(message)
+  }
+}

+ 24 - 0
packages/skills-cli/src/git.ts

@@ -0,0 +1,24 @@
+// 从 git 读取来源信息(仓库、分支、提交号、是否有未提交的改动)与默认的提交说明;不是 git 仓库时返回空
+import { execFileSync } from 'node:child_process'
+
+function git(cwd: string, args: string[]): string | null {
+  try {
+    return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim()
+  } catch {
+    return null
+  }
+}
+
+export function gitInfo(cwd: string): { meta: Record<string, string>; subject: string | null } {
+  if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return { meta: {}, subject: null }
+  const meta: Record<string, string> = {}
+  // 去掉地址中的用户名和密码
+  const remote = git(cwd, ['remote', 'get-url', 'origin'])?.replace(/\/\/[^@/]+@/, '//')
+  if (remote) meta.repo = remote
+  const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
+  if (branch && branch !== 'HEAD') meta.branch = branch
+  const commit = git(cwd, ['rev-parse', 'HEAD'])
+  if (commit) meta.commit = commit
+  if (git(cwd, ['status', '--porcelain'])) meta.dirty = 'true'
+  return { meta, subject: commit ? git(cwd, ['log', '-1', '--pretty=%s']) : null }
+}

+ 500 - 0
packages/skills-cli/src/main.ts

@@ -0,0 +1,500 @@
+// agent-skills:把项目中的技能推送到 AgentServer,审核通过后生效(docs/design/19-skill-cli.md)。
+// 命令:login / logout / whoami / init / push / status / withdraw。退出码:0 成功;1 校验不通过;2 鉴权失败;
+// 3 限额、网络或服务器错误;4 审核被拒绝(--wait);5 等待超时,仍在待审核(--wait)
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import readline from 'node:readline'
+import type { RegistryDeviceCodeDto, RegistryDeviceTokenDto, RegistryItemDto, RegistryMeDto, RegistrySubmissionDto } from '@agent-server/shared'
+import { ApiError, RegistryClient, VERSION } from './api'
+import {
+  CliError, findProject, normalizeServer, PROJECT_FILE, readCredentials, resolveServer, resolveToken, writeCredentials, type Credentials, type Env, type ProjectConfig,
+} from './config'
+import { gitInfo } from './git'
+import { buildZip, detectSkillDirs, LIMITS, readSkill, type LocalSkill } from './pack'
+import { isNewer, latestRelease, NO_UPDATE_ENV, npxArgs, PACKAGE_NAME, type Release } from './update'
+
+export interface Io {
+  out: (s: string) => void
+  err: (s: string) => void
+  env: Env
+  cwd: string
+  /** 输出到终端(决定是否使用颜色、是否询问) */
+  tty: boolean
+  openUrl: (url: string) => Promise<boolean>
+  sleep: (ms: number) => Promise<void>
+  readStdin: () => Promise<string>
+  prompt: (question: string) => Promise<string>
+  /** 用另一个版本重新执行同一条命令,返回它的退出码;无法执行时为 null */
+  relaunch: (release: Release, argv: string[]) => Promise<number | null>
+  /** 当前版本(测试时替换) */
+  version?: string
+}
+
+export function nodeIo(): Io {
+  return {
+    out: s => void process.stdout.write(`${s}\n`),
+    err: s => void process.stderr.write(`${s}\n`),
+    env: process.env,
+    cwd: process.cwd(),
+    tty: !!process.stdout.isTTY,
+    openUrl,
+    sleep: ms => new Promise(r => setTimeout(r, ms)),
+    readStdin: async () => {
+      let s = ''
+      for await (const c of process.stdin) s += c
+      return s
+    },
+    prompt: q => new Promise(resolve => {
+      const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
+      rl.question(q, a => {
+        rl.close()
+        resolve(a)
+      })
+    }),
+    relaunch,
+  }
+}
+
+/** 通过 npx 运行新版本;全局安装的另外提示如何更新本机安装 */
+function relaunch(release: Release, argv: string[]): Promise<number | null> {
+  if (!/[\\/]_npx[\\/]/.test(process.argv[1] ?? '')) process.stderr.write(`本机安装的版本较旧,可以执行 npm i -g --registry ${release.registry} ${PACKAGE_NAME}@latest 更新\n`)
+  return new Promise(resolve => {
+    try {
+      const p = spawn('npx', npxArgs(release, argv), { stdio: 'inherit', env: { ...process.env, [NO_UPDATE_ENV]: '1' }, shell: process.platform === 'win32' })
+      p.on('error', () => resolve(null))
+      p.on('exit', (code, signal) => resolve(signal ? 3 : code ?? 3))
+    } catch {
+      resolve(null)
+    }
+  })
+}
+
+/** 有图形界面时打开浏览器;远程终端(SSH)里只显示地址 */
+async function openUrl(url: string): Promise<boolean> {
+  const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
+    : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
+      : process.env.DISPLAY || process.env.WAYLAND_DISPLAY ? ['xdg-open', [url]] : [null, []]
+  if (!cmd) return false
+  return new Promise(resolve => {
+    try {
+      const p = spawn(cmd, args as string[], { stdio: 'ignore', detached: true })
+      p.on('error', () => resolve(false))
+      p.on('spawn', () => {
+        p.unref()
+        resolve(true)
+      })
+    } catch {
+      resolve(false)
+    }
+  })
+}
+
+// ---- 参数 ----
+
+interface Args {
+  cmd: string
+  pos: string[]
+  flags: Record<string, string | boolean>
+  only: string[]
+}
+
+const VALUE_FLAGS = new Set(['server', 'message', 'only', 'timeout'])
+const ALIASES: Record<string, string> = { m: 'message', h: 'help', v: 'version' }
+
+function parseArgs(argv: string[]): Args {
+  const a: Args = { cmd: '', pos: [], flags: {}, only: [] }
+  for (let i = 0; i < argv.length; i++) {
+    const x = argv[i]!
+    if (x.startsWith('-') && x !== '-') {
+      let [k, v] = x.replace(/^--?/, '').split(/=(.*)/s) as [string, string | undefined]
+      k = ALIASES[k] ?? k
+      if (VALUE_FLAGS.has(k) && v === undefined) {
+        v = argv[++i]
+        if (v === undefined) throw new CliError(`--${k} 需要一个值`, 1)
+      }
+      if (k === 'only') a.only.push(...v!.split(',').map(s => s.trim()).filter(Boolean))
+      else a.flags[k] = v ?? true
+    } else if (!a.cmd) a.cmd = x
+    else a.pos.push(x)
+  }
+  return a
+}
+
+const HELP = `agent-skills ${VERSION}:把项目中的技能推送到 AgentServer,管理员审核通过后生效
+
+用法:agent-skills <命令> [选项]
+
+命令:
+  login [--server 地址]      在浏览器中批准后登录本机(远程终端也可用:在任意电脑的浏览器中打开显示的地址)
+        [--with-token]       改为从标准输入读取后台创建的发布密钥
+        [--no-browser]       不自动打开浏览器
+  logout                     删除本机保存的密钥,并在服务器上停用它
+  whoami                     显示当前使用的密钥
+  init                       在当前目录生成 ${PROJECT_FILE}(服务器地址、技能目录、排除规则;不含密钥)
+  push [目录…]               推送技能(默认按 ${PROJECT_FILE} 中的 paths,或当前目录)
+        --only 名称          只推送指定的技能(可重复,或用逗号分隔)
+        -m, --message 说明   提交说明(默认取最近一次 git 提交的标题)
+        --dry-run            只校验,不生成提交
+        --wait               等待审核完成
+        --timeout 秒数       等待的最长时间,默认 600
+        --strict             把警告(如疑似密钥)当作错误
+        --json               输出 JSON
+  status [提交ID]            查看最近的提交,或某次提交的审核结果
+  withdraw <提交ID>          撤回仍在待审核的技能
+
+通用选项:--server 地址、--json、-h/--help、-v/--version
+
+运行与更新:推荐 npx -y --registry <服务器>/npm/ ${PACKAGE_NAME}@latest <命令>,每次都是最新版本;
+  全局安装或锁定版本时,每次执行前查询仓库中的最新版本,有新版本时自动改用新版本执行(设置 ${NO_UPDATE_ENV}=1 关闭)。
+
+密钥与服务器:
+  环境变量 AGENT_SKILLS_TOKEN、AGENT_SKILLS_SERVER(CI 中使用)优先;
+  其次是项目的 ${PROJECT_FILE}(只含服务器地址)与 login 保存在用户目录中的密钥。
+
+退出码:0 成功(包括待审核、无变化);1 校验不通过;2 鉴权失败;3 限额、网络或服务器错误;
+        4 审核被拒绝(--wait);5 等待超时,仍在待审核(--wait)`
+
+export async function main(argv: string[], io: Io): Promise<number> {
+  try {
+    const a = parseArgs(argv)
+    if (a.flags.version) {
+      io.out(VERSION)
+      return 0
+    }
+    if (!a.cmd || a.flags.help || a.cmd === 'help') {
+      io.out(HELP)
+      return 0
+    }
+    const ctx = new Ctx(a, io)
+    const updated = await ctx.update(argv)
+    if (updated !== null) return updated
+    switch (a.cmd) {
+      case 'login': return await ctx.login()
+      case 'logout': return await ctx.logout()
+      case 'whoami': return await ctx.whoami()
+      case 'init': return ctx.init()
+      case 'push': return await ctx.push()
+      case 'status': return await ctx.status()
+      case 'withdraw': return await ctx.withdraw()
+      default: throw new CliError(`未知命令:${a.cmd}(agent-skills --help 查看用法)`, 1)
+    }
+  } catch (err) {
+    if (err instanceof CliError) {
+      io.err(`错误:${err.message}`)
+      return err.code
+    }
+    throw err
+  }
+}
+
+// ---- 命令 ----
+
+const STATUS: Record<string, string> = {
+  pending: '待审核', approved: '已批准', rejected: '已拒绝', withdrawn: '已撤回', superseded: '已被新的推送替代', invalid: '不能导入', unchanged: '无变化',
+}
+
+class Ctx {
+  private project: { dir: string; config: ProjectConfig } | null
+  private creds: Credentials
+
+  constructor(private a: Args, private io: Io) {
+    this.project = findProject(io.cwd)
+    this.creds = readCredentials(io.env)
+  }
+
+  /** 服务器上有新版本时用新版本执行同一条命令并返回它的退出码;没有新版本、查询失败或无法执行时为 null(继续用当前版本) */
+  async update(argv: string[]): Promise<number | null> {
+    const current = this.io.version ?? VERSION
+    if (current === 'dev' || this.io.env[NO_UPDATE_ENV] || this.a.cmd === 'init') return null
+    const server = this.server(false)
+    if (!server) return null
+    const r = await latestRelease(server)
+    if (!r || !isNewer(r.version, current)) return null
+    this.io.err(`推送工具有新版本 ${r.version}(当前 ${current}),改用新版本执行`)
+    const code = await this.io.relaunch(r, argv)
+    if (code === null) this.io.err(`无法自动更新,继续用 ${current} 执行(可以执行 npx ${npxArgs(r, []).join(' ')} …)`)
+    return code
+  }
+
+  private get json() {
+    return !!this.a.flags.json
+  }
+
+  private c(code: string, s: string) {
+    return this.io.tty && !this.io.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s
+  }
+
+  private server(required = true): string {
+    const s = resolveServer({ server: this.a.flags.server as string | undefined }, this.io.env, this.project?.config, this.creds)
+    if (!s && required) throw new CliError(`没有指定服务器:用 --server 指定,或在 ${PROJECT_FILE} 中填写 server,或设置 AGENT_SKILLS_SERVER`, 1)
+    return s ?? ''
+  }
+
+  private client(): RegistryClient {
+    const server = this.server()
+    return new RegistryClient(server, resolveToken(server, this.io.env, this.creds)?.token ?? null)
+  }
+
+  private print(v: unknown) {
+    this.io.out(JSON.stringify(v, null, 2))
+  }
+
+  // -- 登录 --
+
+  async login(): Promise<number> {
+    let server = this.server(false)
+    if (!server) {
+      if (!this.io.tty) throw new CliError('请用 --server 指定服务器地址', 1)
+      server = normalizeServer(await this.io.prompt('服务器地址(如 https://agent.example.com):'))
+    }
+    const anon = new RegistryClient(server, null)
+    if (this.a.flags['with-token']) {
+      const token = (await this.io.readStdin()).trim()
+      if (!token) throw new CliError('标准输入中没有密钥', 1)
+      const me = await new RegistryClient(server, token).request<RegistryMeDto>('GET', '/me')
+      this.save(server, { token, name: me.name, expiresAt: me.expiresAt })
+      this.io.out(`已登录 ${server}:${me.name}${me.expiresAt ? `(${me.expiresAt.slice(0, 10)} 到期)` : ''}`)
+      return 0
+    }
+    const d = await anon.request<RegistryDeviceCodeDto>('POST', '/device/code', {
+      auth: false, json: { host: os.hostname(), user: safeUser(), platform: `${process.platform}-${process.arch}`, cliVersion: VERSION },
+    })
+    this.io.err(`请在浏览器中打开下面的地址,核对验证码后批准(${Math.round(d.expiresIn / 60)} 分钟内有效):\n\n  ${d.verificationUriComplete}\n\n  验证码:${this.c('1', d.userCode)}\n`)
+    if (!this.a.flags['no-browser'] && await this.io.openUrl(d.verificationUriComplete)) this.io.err('已尝试打开浏览器。')
+    this.io.err('等待批准…(Ctrl+C 取消)')
+    let interval = d.interval
+    const deadline = Date.now() + d.expiresIn * 1000
+    while (Date.now() < deadline) {
+      await this.io.sleep(interval * 1000)
+      try {
+        const t = await anon.request<RegistryDeviceTokenDto>('POST', '/device/token', { auth: false, json: { deviceCode: d.deviceCode } })
+        this.save(server, { token: t.token, name: t.name, expiresAt: t.expiresAt })
+        this.io.out(`已登录 ${server}:${t.name}${t.expiresAt ? `(${t.expiresAt.slice(0, 10)} 到期)` : '(永久有效)'}`)
+        return 0
+      } catch (err) {
+        if (!(err instanceof ApiError)) throw err
+        if (err.errorCode === 'authorization_pending') continue
+        if (err.errorCode === 'slow_down') {
+          interval += 5
+          continue
+        }
+        if (err.errorCode === 'access_denied') throw new CliError('登录申请被拒绝', 2)
+        if (err.errorCode === 'expired_token' || err.errorCode === 'invalid_device_code') throw new CliError('登录申请已过期,请重新执行 agent-skills login', 2)
+        if (err.code === 3 && err.status === 0) continue
+        throw err
+      }
+    }
+    throw new CliError('登录申请已过期,请重新执行 agent-skills login', 2)
+  }
+
+  private save(server: string, v: { token: string; name: string; expiresAt: string | null }) {
+    const c = readCredentials(this.io.env)
+    c.servers[server] = { ...v, savedAt: new Date().toISOString() }
+    c.default = server
+    writeCredentials(this.io.env, c)
+  }
+
+  async logout(): Promise<number> {
+    const server = this.server()
+    const saved = this.creds.servers[server]
+    if (!saved) {
+      this.io.out(`本机没有保存 ${server} 的密钥${this.io.env.AGENT_SKILLS_TOKEN ? '(环境变量 AGENT_SKILLS_TOKEN 中的密钥请在后台停用)' : ''}`)
+      return 0
+    }
+    try {
+      await new RegistryClient(server, saved.token).request('DELETE', '/me')
+      this.io.out(`已在服务器上停用密钥:${saved.name}`)
+    } catch (err) {
+      if (!(err instanceof ApiError) || err.code !== 2) this.io.err(`警告:没能在服务器上停用密钥(${(err as Error).message}),请在后台检查`)
+    }
+    const c = readCredentials(this.io.env)
+    delete c.servers[server]
+    if (c.default === server) c.default = Object.keys(c.servers)[0]
+    writeCredentials(this.io.env, c)
+    this.io.out('已删除本机保存的密钥')
+    return 0
+  }
+
+  async whoami(): Promise<number> {
+    const server = this.server()
+    const t = resolveToken(server, this.io.env, this.creds)
+    if (!t) throw new CliError(`还没有登录 ${server}:执行 agent-skills login`, 2)
+    const me = await new RegistryClient(server, t.token).request<RegistryMeDto>('GET', '/me')
+    if (this.json) this.print({ server, ...me, from: t.from })
+    else {
+      this.io.out(`${server}\n  密钥:${me.name}(${me.prefix}…,${me.origin === 'device' ? '浏览器登录' : '后台创建'})`)
+      this.io.out(`  ${me.expiresAt ? `${me.expiresAt.slice(0, 10)} 到期` : '永久有效'} · 来自${t.from === 'env' ? '环境变量 AGENT_SKILLS_TOKEN' : '本机登录'}`)
+    }
+    return 0
+  }
+
+  init(): number {
+    const file = path.join(this.io.cwd, PROJECT_FILE)
+    if (fs.existsSync(file)) throw new CliError(`${PROJECT_FILE} 已存在`, 1)
+    const server = this.server(false)
+    const paths = fs.existsSync(path.join(this.io.cwd, 'skills')) ? ['skills'] : ['.']
+    const config: ProjectConfig = { ...(server ? { server } : {}), paths, exclude: [] }
+    fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`)
+    const found = paths.flatMap(p => detectSkillDirs(path.join(this.io.cwd, p)))
+    this.io.out(`已生成 ${PROJECT_FILE}(不含密钥,可以提交到仓库);找到 ${found.length} 个技能${found.length ? `:${found.map(d => path.basename(d)).join('、')}` : ''}`)
+    if (!server) this.io.out(`请在其中填写 server(服务器地址)`)
+    return 0
+  }
+
+  // -- 推送 --
+
+  async push(): Promise<number> {
+    const skills = this.collect()
+    const errors = skills.filter(s => s.errors.length)
+    const warned = skills.filter(s => s.warnings.length)
+    if (!this.json) {
+      for (const s of skills) {
+        this.io.out(`${this.c(s.errors.length ? '31' : '32', s.errors.length ? '✗' : '✓')} ${s.name}  ${s.files.length} 个文件 · ${fmtSize(s.size)}${s.declaredVersion ? ` · 版本 ${s.declaredVersion}` : ''}  ${this.c('2', path.relative(this.io.cwd, s.dir) || '.')}`)
+        for (const e of s.errors) this.io.out(`    ${this.c('31', `错误:${e}`)}`)
+        for (const w of s.warnings) this.io.out(`    ${this.c('33', `警告:${w}`)}`)
+        if (s.skipped.length) this.io.out(`    ${this.c('2', `跳过:${s.skipped.join('、')}`)}`)
+      }
+    }
+    if (errors.length) throw new CliError(`${errors.length} 个技能没有通过本地校验,没有推送`, 1)
+    if (this.a.flags.strict && warned.length) throw new CliError('有警告(--strict),没有推送', 1)
+    const zip = buildZip(skills)
+    if (zip.length > LIMITS.zipBytes) throw new CliError(`技能包超过 ${LIMITS.zipBytes / 1048576} MB(${fmtSize(zip.length)}),请用 --only 分批推送`, 1)
+
+    const git = gitInfo(this.project?.dir ?? this.io.cwd)
+    const message = (this.a.flags.message as string | undefined) ?? git.subject ?? ''
+    const form = new FormData()
+    form.append('file', new Blob([new Uint8Array(zip)], { type: 'application/zip' }), 'skills.zip')
+    form.append('message', message)
+    form.append('meta', JSON.stringify({ ...git.meta, cli: VERSION }))
+    const client = this.client()
+    const dryRun = !!this.a.flags['dry-run']
+    let sub = await client.request<RegistrySubmissionDto>('POST', `/submissions${dryRun ? '?dryRun=1' : ''}`, { form })
+
+    if (!this.json) {
+      this.io.out(dryRun ? '\n试运行(没有生成提交):' : `\n已推送 ${sub.id}${message ? `:${message}` : ''}`)
+      this.items(sub.items, client.server)
+    }
+    if (dryRun || !this.a.flags.wait || sub.status === 'done') {
+      if (this.json) this.print(sub)
+      return exitCode(sub, false)
+    }
+
+    // --wait:等待审核完成
+    const timeout = Number(this.a.flags.timeout ?? 600)
+    if (!(timeout > 0)) throw new CliError('--timeout 应为正数(秒)', 1)
+    if (!this.json) this.io.err(`等待审核…(最长 ${timeout} 秒,Ctrl+C 停止等待,推送不受影响)`)
+    const deadline = Date.now() + timeout * 1000
+    while (Date.now() < deadline) {
+      await this.io.sleep(5000)
+      try {
+        sub = await client.request<RegistrySubmissionDto>('GET', `/submissions/${sub.id}`)
+      } catch (err) {
+        if (err instanceof ApiError && err.code === 3) continue
+        throw err
+      }
+      if (sub.status === 'done') break
+    }
+    if (this.json) this.print(sub)
+    else {
+      this.io.out(sub.status === 'done' ? '\n审核结果:' : '\n等待超时,以下技能仍在待审核:')
+      this.items(sub.status === 'done' ? sub.items : sub.items.filter(i => i.status === 'pending'), client.server)
+    }
+    return exitCode(sub, true)
+  }
+
+  /** 找到要推送的技能 */
+  private collect(): LocalSkill[] {
+    const base = this.a.pos.length ? this.io.cwd : this.project?.dir ?? this.io.cwd
+    const roots = (this.a.pos.length ? this.a.pos : this.project?.config.paths ?? ['.']).map(p => path.resolve(base, p))
+    for (const r of roots) if (!fs.existsSync(r)) throw new CliError(`目录不存在:${r}`, 1)
+    const dirs = [...new Set(roots.flatMap(detectSkillDirs))]
+    if (!dirs.length) throw new CliError(`没有找到技能(需要包含 SKILL.md 的目录):${roots.join('、')}`, 1)
+    let skills = dirs.map(d => readSkill(d, this.project?.config.exclude ?? []))
+    const dup = skills.map(s => s.name).filter((n, i, all) => all.indexOf(n) !== i)
+    if (dup.length) throw new CliError(`有多个同名技能:${[...new Set(dup)].join('、')}`, 1)
+    if (this.a.only.length) {
+      const missing = this.a.only.filter(n => !skills.some(s => s.name === n))
+      if (missing.length) throw new CliError(`没有找到技能:${missing.join('、')}(找到的:${skills.map(s => s.name).join('、')})`, 1)
+      skills = skills.filter(s => this.a.only.includes(s.name))
+    }
+    return skills
+  }
+
+  private items(items: RegistryItemDto[], server: string) {
+    for (const i of items) {
+      const color = i.status === 'pending' ? '33' : i.status === 'approved' ? '32' : i.status === 'rejected' || i.status === 'invalid' ? '31' : '2'
+      let line = `  ${i.name}  ${this.c(color, STATUS[i.status] ?? i.status)}`
+      if (i.status === 'pending' && i.existing) line += `(覆盖候选:平台上为 v${i.existing.version}${i.existing.declaredVersion ? `,版本 ${i.existing.declaredVersion}` : ''})`
+      else if (i.status === 'pending') line += '(新技能)'
+      if (i.result) line += `:${i.result.action === 'create' ? '新建' : '覆盖'} ${i.result.skill} v${i.result.version}${i.result.published ? ',已发布' : ',未发布'}`
+      this.io.out(line)
+      if (i.changes && i.status === 'pending') {
+        const c = i.changes
+        const parts = [c.added.length && `新增 ${c.added.length}`, c.modified.length && `修改 ${c.modified.length}`, c.removed.length && `删除 ${c.removed.length}`].filter(Boolean)
+        if (parts.length) this.io.out(`    文件:${parts.join(' · ')}`)
+      }
+      if (i.reason) this.io.out(`    ${this.c('31', i.reason)}`)
+      if (i.reviewNote) this.io.out(`    ${this.c('31', `拒绝原因:${i.reviewNote}`)}`)
+      for (const ch of i.checks) if (ch.level !== 'info') this.io.out(`    ${this.c(ch.level === 'danger' ? '31' : '33', ch.message)}${ch.files?.length ? `:${ch.files.join('、')}` : ''}`)
+      if (i.status === 'pending' && i.id) this.io.out(`    审核:${server}/admin/plugins?tab=custom&review=${i.id}`)
+    }
+  }
+
+  // -- 查询 --
+
+  async status(): Promise<number> {
+    const client = this.client()
+    const id = this.a.pos[0]
+    if (id) {
+      const s = await client.request<RegistrySubmissionDto>('GET', `/submissions/${encodeURIComponent(id)}`)
+      if (this.json) this.print(s)
+      else {
+        this.io.out(`${s.id}  ${s.createdAt.slice(0, 19).replace('T', ' ')}  ${s.status === 'pending' ? '有待审核的技能' : '已全部处理'}${s.message ? `\n  ${s.message}` : ''}`)
+        this.items(s.items, client.server)
+      }
+      return 0
+    }
+    const list = await client.request<RegistrySubmissionDto[]>('GET', '/submissions?limit=10')
+    if (this.json) this.print(list)
+    else if (!list.length) this.io.out('还没有提交')
+    else {
+      for (const s of list) {
+        this.io.out(`${s.id}  ${s.createdAt.slice(0, 19).replace('T', ' ')}  ${s.items.map(i => `${i.name}(${STATUS[i.status] ?? i.status})`).join('、')}${s.message ? `  ${this.c('2', s.message)}` : ''}`)
+      }
+    }
+    return 0
+  }
+
+  async withdraw(): Promise<number> {
+    const id = this.a.pos[0]
+    if (!id) throw new CliError('用法:agent-skills withdraw <提交ID>', 1)
+    const client = this.client()
+    const s = await client.request<RegistrySubmissionDto>('DELETE', `/submissions/${encodeURIComponent(id)}`)
+    if (this.json) this.print(s)
+    else {
+      this.io.out(`${s.id}:`)
+      this.items(s.items, client.server)
+    }
+    return 0
+  }
+}
+
+/** 推送的退出码:有不能导入的技能为 1;等待时有被拒绝的为 4,仍有待审核的为 5 */
+function exitCode(s: RegistrySubmissionDto, waited: boolean): number {
+  if (s.items.some(i => i.status === 'invalid')) return 1
+  if (waited && s.items.some(i => i.status === 'rejected')) return 4
+  if (waited && s.items.some(i => i.status === 'pending')) return 5
+  return 0
+}
+
+function safeUser(): string {
+  try {
+    return os.userInfo().username
+  } catch {
+    return ''
+  }
+}
+
+const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)

+ 167 - 0
packages/skills-cli/src/pack.ts

@@ -0,0 +1,167 @@
+// 本地扫描与打包:识别技能目录(规则与服务端一致:单个技能、<名称>/SKILL.md、skills/<名称>/SKILL.md),
+// 推送前预检(名称、SKILL.md、限额、疑似密钥),按 <名称>/<文件> 打成 zip
+import fs from 'node:fs'
+import path from 'node:path'
+import { zipSync } from 'fflate'
+
+export const LIMITS = { filesPerSkill: 500, bytesPerSkill: 20 * 1048576, bytesPerFile: 10 * 1048576, zipBytes: 50 * 1048576 }
+export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
+const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', '.idea', '.vscode'])
+const DEFAULT_EXCLUDE = ['.DS_Store', 'Thumbs.db', '*.pyc', '.env', '.env.*', '!.env.example']
+const TEXT_EXT = /\.(md|markdown|txt|json|jsonl|ya?ml|csv|tsv|py|js|mjs|cjs|ts|sh|bash|toml|ini|cfg|conf|xml|html?|css|sql|r|rb|go|java|tex|svg)$/i
+const SECRET_PATTERNS: [RegExp, string][] = [
+  [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, '私钥'],
+  [/\bAKIA[0-9A-Z]{16}\b/, 'AWS Access Key'],
+  [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, 'API Key(sk-…)'],
+  [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'GitHub 令牌'],
+  [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'],
+  [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, 'AgentServer 密钥'],
+  [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'],
+  [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '密码或密钥赋值'],
+]
+
+export interface LocalSkill {
+  name: string
+  /** 技能目录(绝对路径) */
+  dir: string
+  files: { rel: string; abs: string; size: number }[]
+  size: number
+  description: string
+  declaredVersion: string | null
+  errors: string[]
+  warnings: string[]
+  /** 跳过的文件(符号链接、排除的文件) */
+  skipped: string[]
+}
+
+/** 在根目录中找技能目录;根目录只有一个子目录时先进入它 */
+export function detectSkillDirs(root: string): string[] {
+  const has = (d: string) => isFile(path.join(d, 'SKILL.md'))
+  if (has(root)) return [root]
+  const found: string[] = []
+  for (const parent of ['skills', '.']) {
+    const dir = path.join(root, parent)
+    for (const e of entries(dir)) {
+      if (!e.isDirectory() || e.name.startsWith('.')) continue
+      const abs = path.join(dir, e.name)
+      if (has(abs) && !found.includes(abs)) found.push(abs)
+    }
+  }
+  if (!found.length) {
+    const list = entries(root).filter(e => !e.name.startsWith('.'))
+    if (list.length === 1 && list[0]!.isDirectory()) return detectSkillDirs(path.join(root, list[0]!.name))
+  }
+  return found.sort()
+}
+
+/** 读取并预检一个技能目录 */
+export function readSkill(dir: string, exclude: string[] = []): LocalSkill {
+  const rules = [...DEFAULT_EXCLUDE, ...exclude].map(compileRule)
+  const skipped: string[] = []
+  const files = listFiles(dir, dir, rules, skipped)
+  const md = files.find(f => f.rel === 'SKILL.md')
+  const text = md ? fs.readFileSync(md.abs, 'utf8') : ''
+  const fm = frontmatter(text)
+  const name = fm.name && NAME_RE.test(fm.name) ? fm.name : path.basename(dir)
+  const s: LocalSkill = {
+    name, dir, files, size: files.reduce((n, f) => n + f.size, 0), description: fm.description ?? '', declaredVersion: declaredVersion(text),
+    errors: [], warnings: [], skipped,
+  }
+  if (!NAME_RE.test(name)) s.errors.push(`技能名 ${name} 不符合要求(小写字母、数字、连字符,1-64 位)`)
+  if (!md) s.errors.push('缺少 SKILL.md')
+  else if (fm.name !== name) s.warnings.push(fm.name ? `SKILL.md 中的 name(${fm.name})与目录名不同,将使用 ${name}` : `SKILL.md 缺少 name,将使用目录名 ${name}`)
+  if (md && !s.description.trim()) s.errors.push('SKILL.md 缺少 description(它决定 Agent 是否会使用这个技能)')
+  else if (md && s.description.length < 20) s.warnings.push('description 过短:它决定 Agent 是否会使用这个技能,建议写清楚适用场景')
+  if (files.length > LIMITS.filesPerSkill) s.errors.push(`文件超过 ${LIMITS.filesPerSkill} 个(${files.length} 个)`)
+  if (s.size > LIMITS.bytesPerSkill) s.errors.push(`技能超过 ${LIMITS.bytesPerSkill / 1048576} MB`)
+  for (const f of files) if (f.size > LIMITS.bytesPerFile) s.errors.push(`文件 ${f.rel} 超过 ${LIMITS.bytesPerFile / 1048576} MB`)
+  for (const f of files) {
+    if (!(TEXT_EXT.test(f.rel) || !path.basename(f.rel).includes('.')) || f.size > 1048576) continue
+    const t = fs.readFileSync(f.abs, 'utf8')
+    for (const [re, label] of SECRET_PATTERNS) if (re.test(t)) s.warnings.push(`${f.rel} 可能包含${label}`)
+  }
+  return s
+}
+
+/** 打包:每个技能放在 <名称>/ 下 */
+export function buildZip(skills: LocalSkill[]): Buffer {
+  const entries: Record<string, Uint8Array> = {}
+  for (const s of skills) for (const f of s.files) entries[`${s.name}/${f.rel}`] = new Uint8Array(fs.readFileSync(f.abs))
+  return Buffer.from(zipSync(entries, { level: 6 }))
+}
+
+// ---- 内部 ----
+
+function listFiles(dir: string, base: string, rules: Rule[], skipped: string[]): { rel: string; abs: string; size: number }[] {
+  const out: { rel: string; abs: string; size: number }[] = []
+  for (const e of entries(dir)) {
+    const abs = path.join(dir, e.name)
+    const rel = path.relative(base, abs).split(path.sep).join('/')
+    if (e.isSymbolicLink()) {
+      skipped.push(`${rel}(符号链接)`)
+    } else if (e.isDirectory()) {
+      if (SKIP_DIRS.has(e.name) || excluded(rel, rules, true)) continue
+      out.push(...listFiles(abs, base, rules, skipped))
+    } else if (e.isFile()) {
+      if (excluded(rel, rules, false)) skipped.push(`${rel}(已排除)`)
+      else out.push({ rel, abs, size: fs.statSync(abs).size })
+    }
+  }
+  return out.sort((a, b) => a.rel.localeCompare(b.rel))
+}
+
+interface Rule { re: RegExp; negate: boolean; base: boolean }
+
+/** 通配符:* 不跨目录,** 跨目录;不含 / 的规则匹配文件名,含 / 的匹配相对路径;! 开头表示不排除 */
+function compileRule(raw: string): Rule {
+  const negate = raw.startsWith('!')
+  const p = (negate ? raw.slice(1) : raw).replace(/^\.\//, '').replace(/\/$/, '')
+  const re = p.split('**').map(part => part.split('*').map(x => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*')
+  return { re: new RegExp(`^${re}$`), negate, base: !p.includes('/') }
+}
+
+function excluded(rel: string, rules: Rule[], _dir: boolean): boolean {
+  let out = false
+  for (const r of rules) if (r.re.test(r.base ? path.posix.basename(rel) : rel)) out = !r.negate
+  return out
+}
+
+function entries(dir: string): fs.Dirent[] {
+  try {
+    return fs.readdirSync(dir, { withFileTypes: true })
+  } catch {
+    return []
+  }
+}
+
+function isFile(p: string) {
+  try {
+    return fs.lstatSync(p).isFile()
+  } catch {
+    return false
+  }
+}
+
+function frontmatter(md: string): Record<string, string> {
+  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)?.[1] ?? ''
+  const out: Record<string, string> = {}
+  const lines = block.split(/\r?\n/)
+  for (let i = 0; i < lines.length; i++) {
+    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(lines[i]!)
+    if (!kv) continue
+    let v = kv[2]!.trim()
+    if (/^[>|][+-]?$/.test(v)) {
+      const b: string[] = []
+      while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]!) || lines[i + 1] === '')) b.push(lines[++i]!.trim())
+      v = b.join(v.startsWith('>') ? ' ' : '\n').trim()
+    }
+    out[kv[1]!] = v.replace(/^["']|["']$/g, '').trim()
+  }
+  return out
+}
+
+function declaredVersion(md: string): string | null {
+  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)?.[1] ?? ''
+  const v = /^version:\s*(.+)$/m.exec(fm) ?? /^metadata:[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+version:\s*(.+)$/m.exec(fm)
+  return v ? v[1]!.trim().replace(/^["']|["']$/g, '').slice(0, 40) || null : null
+}

+ 43 - 0
packages/skills-cli/src/update.ts

@@ -0,0 +1,43 @@
+// 自动更新:推荐用 npx …@latest 运行,npx 每次向平台的 npm 仓库(<服务器>/npm/)确认最新版本。
+// 全局安装或锁定了版本时,每次执行命令前查询仓库中的最新版本,比当前版本新时改用新版本重新执行同一条命令
+export const PACKAGE_NAME = '@agent-server/skills-cli'
+/** 设置后不检查更新(重新执行时也会设置,避免循环) */
+export const NO_UPDATE_ENV = 'AGENT_SKILLS_NO_UPDATE_CHECK'
+
+export interface Release {
+  version: string
+  /** 平台的 npm 仓库地址(以 / 结尾) */
+  registry: string
+}
+
+/** 平台的 npm 仓库地址 */
+export const registryOf = (server: string) => `${server}/npm/`
+
+/** 用 npx 运行某个版本的参数 */
+export const npxArgs = (r: Release, argv: string[]) => ['-y', '--registry', r.registry, `${PACKAGE_NAME}@${r.version}`, ...argv]
+
+/** a 比 b 新(x.y.z,数字逐段比较) */
+export function isNewer(a: string, b: string): boolean {
+  const pa = a.split('.').map(Number)
+  const pb = b.split('.').map(Number)
+  for (let i = 0; i < 3; i++) {
+    const d = (pa[i] ?? 0) - (pb[i] ?? 0)
+    if (Number.isNaN(d)) return false
+    if (d !== 0) return d > 0
+  }
+  return false
+}
+
+/** 仓库中推送工具的最新版本;查询失败(网络、旧服务器、超时 3 秒)时为 null,不影响命令执行 */
+export async function latestRelease(server: string): Promise<Release | null> {
+  try {
+    const registry = registryOf(server)
+    const res = await fetch(`${registry}${PACKAGE_NAME.replace('/', '%2f')}`, { signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } })
+    if (!res.ok) return null
+    const doc = (await res.json()) as { 'dist-tags'?: { latest?: string } }
+    const version = doc['dist-tags']?.latest
+    return version ? { version, registry } : null
+  } catch {
+    return null
+  }
+}

+ 5 - 0
packages/skills-cli/tsconfig.json

@@ -0,0 +1,5 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": { "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"] },
+  "include": ["src"]
+}

+ 17 - 0
packages/skills-cli/tsup.config.ts

@@ -0,0 +1,17 @@
+import { readFileSync } from 'node:fs'
+import { defineConfig } from 'tsup'
+
+const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }
+
+// 推送工具打包为一个不带依赖的文件(fflate 一起打包),由 scripts/build-sdk.mjs 放进 dist-sdk 分发(docs/design/19-skill-cli.md)
+export default defineConfig({
+  entry: { 'agent-skills': 'src/bin.ts' },
+  format: ['esm'],
+  platform: 'node',
+  target: 'node18',
+  outExtension: () => ({ js: '.mjs' }),
+  noExternal: [/.*/],
+  banner: { js: '#!/usr/bin/env node' },
+  define: { __CLI_VERSION__: JSON.stringify(version) },
+  clean: true,
+})

+ 19 - 0
pnpm-lock.yaml

@@ -292,6 +292,25 @@ importers:
         specifier: ^5.9.3
         version: 5.9.3
 
+  packages/skills-cli:
+    dependencies:
+      fflate:
+        specifier: ^0.8.2
+        version: 0.8.3
+    devDependencies:
+      '@agent-server/shared':
+        specifier: workspace:*
+        version: link:../shared
+      '@types/node':
+        specifier: ^26.6.5
+        version: 26.6.5
+      tsup:
+        specifier: ^9.0.0
+        version: 9.0.0(tsx@4.23.15)(typescript@5.9.3)
+      typescript:
+        specifier: ^5.9.3
+        version: 5.9.3
+
   packages/ui-tokens:
     devDependencies:
       typescript:

+ 18 - 4
scripts/build-sdk.mjs

@@ -1,7 +1,8 @@
 #!/usr/bin/env node
-// 构建业务平台使用的 SDK 包(docs/integration.md):@agent-server/shared、@agent-server/client、@agent-server/chat-ui。
-// 输出 dist-sdk/<包名>-<版本>.tgz 与 dist-sdk/index.json,由服务在 /sdk/ 下公开提供(不含任何密钥)。
-// 包之间是对等依赖:业务平台用 URL 同时安装三个包,pnpm 按包名把它们互相关联,不会去 npm 仓库查找。
+// 构建业务平台使用的 SDK 包(docs/integration.md):@agent-server/shared、@agent-server/client、@agent-server/chat-ui,
+// 以及技能推送工具 agent-skills(docs/design/19-skill-cli.md)。
+// 输出 dist-sdk/<包名>-<版本>.tgz,部署到 app/sdk(保留旧版本),由服务的 npm 仓库 /npm/ 公开提供(不含任何密钥)。
+// 包之间是对等依赖;业务平台用 @agent-server:registry=<服务器>/npm/ 安装,其余依赖仍从 npm 官方仓库安装。
 import { execFileSync } from 'node:child_process'
 import fs from 'node:fs'
 import path from 'node:path'
@@ -21,6 +22,7 @@ run('npx', ['tsup', '--silent'], pkgDir('shared'))
 run('npx', ['tsup', '--silent'], pkgDir('client'))
 run('npx', ['vite', 'build', '--logLevel', 'warn'], pkgDir('chat-ui'))
 run('npx', ['vue-tsc', '-p', 'tsconfig.build.json'], pkgDir('chat-ui'))
+run('npx', ['tsup', '--silent'], pkgDir('skills-cli'))
 
 const version = readPkg('client').version
 const peer = { '@agent-server/shared': version }
@@ -55,6 +57,18 @@ for (const p of packages) {
   const file = execFileSync('npm', ['pack', '--silent', '--pack-destination', out], { cwd: dir, encoding: 'utf8' }).trim().split('\n').pop()
   index.push({ name: src.name, version, file })
 }
+
+// 推送工具:单文件、不带依赖,版本独立
+{
+  const src = readPkg('skills-cli')
+  const dir = path.join(stage, 'skills-cli')
+  fs.mkdirSync(dir, { recursive: true })
+  fs.copyFileSync(path.join(pkgDir('skills-cli'), 'dist/agent-skills.mjs'), path.join(dir, 'agent-skills.mjs'))
+  fs.chmodSync(path.join(dir, 'agent-skills.mjs'), 0o755)
+  const manifest = { name: src.name, version: src.version, type: 'module', description: src.description, bin: { 'agent-skills': './agent-skills.mjs' }, engines: { node: '>=18' } }
+  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest, null, 2))
+  const file = execFileSync('npm', ['pack', '--silent', '--pack-destination', out], { cwd: dir, encoding: 'utf8' }).trim().split('\n').pop()
+  index.push({ name: src.name, version: src.version, file })
+}
 fs.rmSync(stage, { recursive: true, force: true })
-fs.writeFileSync(path.join(out, 'index.json'), JSON.stringify({ version, packages: index }, null, 2))
 console.log(`SDK ${version}:${index.map(p => p.file).join('、')}`)