mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge remote-tracking branch 'origin/main' into feat/iteration1-w1-foundation
This commit is contained in:
@@ -66,6 +66,7 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `npx skills add modelstudioai/cl
|
||||
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# 埋点变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 调整 AEM 命令事件、事件字段或参数 allowlist
|
||||
- 调整 `User-Agent`、`x-dashscope-source-config` 或其他后端渠道标识
|
||||
- 新增鉴权域、请求网关或绕开统一 Client 的网络出口
|
||||
- 排查命令量、成功率、版本、鉴权域或后端渠道数据不一致
|
||||
|
||||
## 当前数据流
|
||||
|
||||
三套鉴权对应三套请求域,但不代表三套网关使用相同的后端埋点。命令侧另有一套覆盖所有实际执行命令的 AEM 客户端事件,两者必须分开理解。
|
||||
|
||||
```text
|
||||
命令进入 run
|
||||
├─ telemetryStage
|
||||
│ ├─ ~/.bailian/telemetry.jsonl
|
||||
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
|
||||
│
|
||||
└─ authStage
|
||||
├─ apiKey → DashScope / 模型域
|
||||
├─ console → Bailian Console Gateway
|
||||
├─ openapi → 阿里云 OpenAPI
|
||||
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
|
||||
```
|
||||
|
||||
### 1. 三套鉴权与埋点标识
|
||||
|
||||
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) |
|
||||
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` |
|
||||
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 无 | 有:`pid=bailian-cli-node`、`authMethod=console` |
|
||||
| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=openapi` |
|
||||
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` |
|
||||
|
||||
`authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。
|
||||
鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录,Managed Agent 还可能访问其他 provider。
|
||||
|
||||
表中的后端埋点按该鉴权域的主要业务请求填写:
|
||||
|
||||
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
|
||||
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
|
||||
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识
|
||||
|
||||
### 2. 后端渠道参数
|
||||
|
||||
当前 `x-dashscope-source-config` 结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "bailian-cli",
|
||||
"tags": {
|
||||
"t1": "public",
|
||||
"t2": "bl 或 kscli",
|
||||
"t3": "实际 CLI 版本"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `t2` 取产品 `identity.binName`:完整 CLI 为 `bl`,Knowledge Studio CLI 为 `kscli`
|
||||
- `t3` 取产品 `identity.version`,由产品入口的 `package.json` 注入
|
||||
- `channel` 与 `t1` 是当前固定口径
|
||||
- `User-Agent` 是独立标识:`bl` 为 `bailian-cli/<version>`,`kscli` 为 `knowledge-studio-cli/<version>`
|
||||
|
||||
source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网络传输:
|
||||
|
||||
| 请求 | source-config |
|
||||
| ------------------------------------ | ------------- |
|
||||
| 模型 API、任务提交与轮询 | 有 |
|
||||
| Bailian MCP / OpenAPI | 有 |
|
||||
| DashScope 上传策略 `getPolicy` | 有 |
|
||||
| OSS 文件上传 | 无 |
|
||||
| 图片、视频、音频、转录结果下载 | 无 |
|
||||
| npm / 二进制更新检查、Skill registry | 无 |
|
||||
|
||||
当前已知例外:Pipeline runtime 自建的 `Identity.version` 为 `0.0.0-dev`,因此 Pipeline 内部模型请求的 `t3` 不代表产品包版本;现阶段不纳入本轮收敛。
|
||||
|
||||
### 3. 全命令 AEM 客户端埋点
|
||||
|
||||
`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。
|
||||
|
||||
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`:
|
||||
|
||||
- 根帮助、子命令 `--help`、`--version`
|
||||
- 未识别命令、参数解析失败、缺少必填参数
|
||||
- `defineCommand.validate` 在 dispatch 阶段拒绝的请求
|
||||
|
||||
遥测默认开启;`DO_NOT_TRACK=1` 一票否决,配置文件 `telemetry: false` 也可关闭。关闭后本地和远端均不记录。
|
||||
|
||||
单条 `TrackingEvent` 当前包含:
|
||||
|
||||
- `command`、`timestamp`、`durationMs`、`success`
|
||||
- `cliVersion`、`nodeVersion`、`os`
|
||||
- `authMethod`
|
||||
- 失败时的 `errorMessage`、`httpStatus`、`requestId`
|
||||
- 安全 allowlist 过滤后的 `params`
|
||||
|
||||
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
|
||||
|
||||
事件同时写入两处:
|
||||
|
||||
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建
|
||||
2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod`
|
||||
|
||||
底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。
|
||||
|
||||
当前 AEM 事件没有 `binName` 或 `clientName` 产品维度,并且 `bl`、`kscli` 共用 `pid=bailian-cli-node`。两边相同路径的 `config show`、`config set`、`update` 无法仅凭当前事件稳定区分产品;Knowledge 命令虽然因路径映射不同而表现为 `knowledge chat` 与 `chat`,也不应把命令路径当作长期产品标识。后端 source-config 的 `t2` 已能区分 `bl/kscli`,但这个维度尚未进入 AEM 客户端事件。
|
||||
|
||||
AEM 映射:
|
||||
|
||||
| AEM 字段 | 内容 |
|
||||
| ---------- | ----------------------------------------- |
|
||||
| event name | 命令路径 |
|
||||
| `et` | `EXP` |
|
||||
| `ext` | 除 `command`、`params` 外的结构化事件字段 |
|
||||
| `c1` | allowlist 参数 |
|
||||
| `c2` | `success` / `failure` |
|
||||
| `c3` | HTTP status |
|
||||
| `c4` | 错误文案,最多 500 字符 |
|
||||
| `c5` | request ID |
|
||||
|
||||
远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. 新增或调整命令
|
||||
|
||||
- [ ] `defineCommand({ auth })` 必须声明真实请求域;AEM 的 `authMethod` 直接读取该值
|
||||
- [ ] 新命令进入 `run` 后自动有基础事件,不得在命令内重复发送同名事件
|
||||
- [ ] 需要按产品分析 AEM 数据时,必须显式设计产品字段;不得从命令路径推断 `bl/kscli`
|
||||
- [ ] 只有可枚举、数值或布尔等低风险字段才可加入 `PARAM_ALLOWLIST`
|
||||
- [ ] 新增 console raw API flag 时只允许记录公开 API 名,不得记录请求 `data`
|
||||
|
||||
### B. 调整后端渠道参数
|
||||
|
||||
- [ ] 同时核对 `packages/core/src/client/http.ts`、`mcp.ts`、`instrumented-fetch.ts`、`client.ts` 与 `files/upload.ts`
|
||||
- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测
|
||||
- [ ] `bl` 与 `kscli` 必须分别验证 `binName`、`clientName`、`version`
|
||||
- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config
|
||||
- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单
|
||||
|
||||
### C. 调整 AEM 事件
|
||||
|
||||
- [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射
|
||||
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
|
||||
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
|
||||
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
|
||||
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
|
||||
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts'
|
||||
vp check
|
||||
vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计
|
||||
- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域
|
||||
- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config,或把它发给 OSS / npm / 第三方下载地址
|
||||
- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误
|
||||
- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware
|
||||
@@ -9,7 +9,8 @@
|
||||
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
||||
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
|
||||
* legacy fallback to skill.tar.br when the entry has no valid object field
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
|
||||
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
|
||||
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
|
||||
* 5. Write ~/.bailian/wiki-sync-state.json
|
||||
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
|
||||
@@ -20,10 +21,12 @@
|
||||
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
|
||||
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
@@ -106,6 +109,9 @@ async function downloadBuffer(url) {
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
function isSafeEntryName(name) {
|
||||
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
|
||||
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
|
||||
if (name.includes("\\") || name.includes("\0")) return false;
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
@@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the publisher's deterministic content hash over an extracted directory
|
||||
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
|
||||
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
|
||||
*/
|
||||
function computeDirContentHash(dir) {
|
||||
const relPaths = [];
|
||||
const walk = (sub) => {
|
||||
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
|
||||
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) walk(rel);
|
||||
else if (dirent.isFile()) relPaths.push(rel);
|
||||
}
|
||||
};
|
||||
walk("");
|
||||
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
const hash = createHash("sha256");
|
||||
for (const rel of relPaths) {
|
||||
hash.update(rel);
|
||||
hash.update(readFileSync(join(dir, rel)));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
||||
function atomicSwap(tmpDir, catalogDir) {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
@@ -166,12 +196,22 @@ async function main() {
|
||||
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
|
||||
|
||||
// 3. Extract to same-volume temp dir + atomic swap
|
||||
// 3. Extract to same-volume temp dir + integrity check + atomic swap
|
||||
const catalogDir = getCatalogDir();
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBuf, tmpDir);
|
||||
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
|
||||
// before touching the canonical dir
|
||||
if (entry.contentHash.startsWith("sha256:")) {
|
||||
const actualContentHash = computeDirContentHash(tmpDir);
|
||||
if (actualContentHash !== entry.contentHash) {
|
||||
throw new Error(
|
||||
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
atomicSwap(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -56,7 +56,12 @@ export default defineCommand({
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
const record = await installSkillWithFanout(
|
||||
name,
|
||||
entry,
|
||||
agents,
|
||||
lock.skills[name]?.links ?? [],
|
||||
);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fanOutSkillToAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkillWithFanout,
|
||||
@@ -45,6 +46,7 @@ export default defineCommand({
|
||||
const lock = readSkillLock();
|
||||
const disk = new Set(listSkillDirsOnDisk());
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
const results: UpdateOutcome[] = [];
|
||||
const targets: string[] = [];
|
||||
if (requested === "all") {
|
||||
@@ -60,6 +62,11 @@ export default defineCommand({
|
||||
continue;
|
||||
}
|
||||
if (entry.contentHash === locked.contentHash && disk.has(name)) {
|
||||
// Self-healing: content unchanged, but still fill fan-out links for agents
|
||||
// detected since the last install (and refresh recorded copies); the merged
|
||||
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
|
||||
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
|
||||
lock.skills[name] = { ...locked, links: fanout.links };
|
||||
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
|
||||
continue;
|
||||
}
|
||||
@@ -80,14 +87,18 @@ export default defineCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
const record = await installSkillWithFanout(
|
||||
name,
|
||||
entry,
|
||||
agents,
|
||||
lock.skills[name]?.links ?? [],
|
||||
);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return { name, status: "updated", publishedAt: entry.publishedAt };
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type DashScopeASRRequest,
|
||||
type DashScopeASRTaskResult,
|
||||
type DashScopeAsyncResponse,
|
||||
trackingHeaders,
|
||||
stripUndefined,
|
||||
taskPath,
|
||||
speechRecognizePath,
|
||||
@@ -201,9 +200,7 @@ async function handleAsyncMode(
|
||||
}
|
||||
|
||||
// Fetch transcription JSON
|
||||
const transRes = await fetch(subResult.transcription_url, {
|
||||
headers: trackingHeaders(),
|
||||
});
|
||||
const transRes = await fetch(subResult.transcription_url);
|
||||
if (!transRes.ok) {
|
||||
throw new BailianError(
|
||||
`Failed to download transcription: HTTP ${transRes.status}`,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts";
|
||||
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
|
||||
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
|
||||
import { fetchSkillsIndex } from "../skills/registry.ts";
|
||||
@@ -90,12 +91,17 @@ function recordWikiInLock(lockEntry: SkillLockEntry): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
|
||||
function wikiLockUpToDate(contentHash: string): boolean {
|
||||
/**
|
||||
* Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the
|
||||
* record carries no fan-out links (postinstall writes contentHash only and never fans
|
||||
* out, so agents would otherwise never see the wiki skill until content changes).
|
||||
*/
|
||||
function wikiLockNeedsBackfill(contentHash: string): boolean {
|
||||
try {
|
||||
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
|
||||
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
|
||||
return locked?.contentHash !== contentHash || !Array.isArray(locked.links);
|
||||
} catch {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,15 +142,25 @@ export async function maybeSyncWikiData(): Promise<boolean> {
|
||||
const dataOk = catalogDataExists();
|
||||
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
|
||||
writeState({ lastChecked: now, contentHash: entry.contentHash });
|
||||
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
|
||||
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
|
||||
// Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill
|
||||
if (wikiLockNeedsBackfill(entry.contentHash)) {
|
||||
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
|
||||
const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks);
|
||||
recordWikiInLock(buildSkillLockEntry(entry, fanout.links));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Different content or missing data: delegate to the shared skill install pipeline
|
||||
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
|
||||
try {
|
||||
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
|
||||
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
|
||||
const record = await installSkillWithFanout(
|
||||
WIKI_SKILL_NAME,
|
||||
entry,
|
||||
detectInstalledAgents(),
|
||||
previousLinks,
|
||||
);
|
||||
recordWikiInLock(record.lockEntry);
|
||||
} catch {
|
||||
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
|
||||
|
||||
@@ -126,7 +126,10 @@ export class Client {
|
||||
/** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */
|
||||
uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise<string> {
|
||||
if (!isLocalFile(source)) return Promise.resolve(source);
|
||||
return resolveFileUrl(source, this.requireApi().token, model, opts);
|
||||
return resolveFileUrl(source, this.requireApi().token, model, {
|
||||
...opts,
|
||||
identity: this.deps.identity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +236,7 @@ export class Client {
|
||||
const timeoutMs = this.deps.settings.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: opts.method,
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
headers: { ...headers, ...trackingHeaders(this.deps.identity) },
|
||||
body: bodyStr || undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
/**
|
||||
* Shared HTTP request headers for all outgoing requests.
|
||||
*
|
||||
* Centralises the `x-dashscope-source-config` header so every fetch call
|
||||
* (both via the central http client and the bypass paths) uses the
|
||||
* same values from a single source of truth.
|
||||
* Centralises the `x-dashscope-source-config` header so Bailian/DashScope API
|
||||
* transports use the same product identity. Generic npm, OSS, and result-file
|
||||
* transfers deliberately do not send this gateway-consumed metadata.
|
||||
*/
|
||||
|
||||
import type { Identity } from "../config/schema.ts";
|
||||
|
||||
export const CHANNEL = "bailian-cli";
|
||||
|
||||
export const TAGS = { t1: "public", t2: "" };
|
||||
export type TrackingIdentity = Pick<Identity, "binName" | "version">;
|
||||
|
||||
export const SOURCE_CONFIG = JSON.stringify({
|
||||
channel: CHANNEL,
|
||||
tags: TAGS,
|
||||
});
|
||||
export function sourceConfig(identity: TrackingIdentity): string {
|
||||
return JSON.stringify({
|
||||
channel: CHANNEL,
|
||||
tags: {
|
||||
t1: "public",
|
||||
t2: identity.binName,
|
||||
t3: identity.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Standard tracking headers required on every outbound request. */
|
||||
export function trackingHeaders(): Record<string, string> {
|
||||
/** Tracking headers for Bailian/DashScope API requests. */
|
||||
export function trackingHeaders(identity: TrackingIdentity): Record<string, string> {
|
||||
return {
|
||||
"x-dashscope-source-config": SOURCE_CONFIG,
|
||||
"x-dashscope-source-config": sourceConfig(identity),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { mapApiError } from "../errors/api.ts";
|
||||
import { maskToken } from "../utils/token.ts";
|
||||
import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts";
|
||||
import { sourceConfig, trackingHeaders } from "./headers.ts";
|
||||
|
||||
/** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */
|
||||
export interface HttpDeps {
|
||||
@@ -39,7 +39,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise<Respon
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": `${deps.identity.clientName}/${deps.identity.version}`,
|
||||
...trackingHeaders(),
|
||||
...trackingHeaders(deps.identity),
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise<Respon
|
||||
console.error(`> ${opts.method ?? "GET"} ${opts.url}`);
|
||||
const auth = headers["Authorization"];
|
||||
if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`);
|
||||
console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`);
|
||||
console.error(`> x-dashscope-source-config: ${sourceConfig(deps.identity)}`);
|
||||
}
|
||||
|
||||
const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000;
|
||||
|
||||
@@ -36,7 +36,7 @@ export {
|
||||
type ImageInputStyle,
|
||||
type ImageSizeProfile,
|
||||
} from "./image-routes.ts";
|
||||
export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
|
||||
export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts";
|
||||
export type { HttpDeps, RequestOpts } from "./http.ts";
|
||||
export { request, requestJson } from "./http.ts";
|
||||
export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts";
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation {
|
||||
headers.set("User-Agent", `${deps.identity.clientName}/${deps.identity.version}`);
|
||||
}
|
||||
if (isAlibabaCloudHost(url)) {
|
||||
for (const [name, value] of Object.entries(trackingHeaders())) {
|
||||
for (const [name, value] of Object.entries(trackingHeaders(deps.identity))) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class McpClient {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
|
||||
...trackingHeaders(),
|
||||
...trackingHeaders(this.deps.identity),
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename, extname } from "path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { trackingHeaders } from "../client/headers.ts";
|
||||
import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts";
|
||||
import { REGIONS } from "../config/schema.ts";
|
||||
|
||||
// Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement.
|
||||
@@ -36,6 +36,7 @@ interface UploadPolicyResponse {
|
||||
async function getUploadPolicy(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
identity: TrackingIdentity,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UploadPolicy> {
|
||||
const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`;
|
||||
@@ -44,7 +45,7 @@ async function getUploadPolicy(
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...trackingHeaders(),
|
||||
...trackingHeaders(identity),
|
||||
},
|
||||
signal: policySignal.signal,
|
||||
}).finally(policySignal.cleanup);
|
||||
@@ -87,9 +88,6 @@ async function uploadToOSS(
|
||||
const uploadSignal = combineWithTimeout(120_000, signal);
|
||||
const res = await fetch(policy.upload_host, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...trackingHeaders(),
|
||||
},
|
||||
body: form,
|
||||
signal: uploadSignal.signal,
|
||||
}).finally(uploadSignal.cleanup);
|
||||
@@ -109,6 +107,7 @@ export interface UploadOptions {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
filePath: string;
|
||||
identity: TrackingIdentity;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -160,7 +159,7 @@ export function redactDataUri(input: string): string {
|
||||
* The URL is valid for 48 hours.
|
||||
*/
|
||||
export async function uploadFile(opts: UploadOptions): Promise<string> {
|
||||
const { apiKey, model, filePath, signal } = opts;
|
||||
const { apiKey, model, filePath, identity, signal } = opts;
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
|
||||
@@ -171,7 +170,7 @@ export async function uploadFile(opts: UploadOptions): Promise<string> {
|
||||
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const policy = await getUploadPolicy(apiKey, model, signal);
|
||||
const policy = await getUploadPolicy(apiKey, model, identity, signal);
|
||||
return uploadToOSS(policy, filePath, signal);
|
||||
}
|
||||
|
||||
@@ -193,10 +192,16 @@ export async function resolveFileUrl(
|
||||
input: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
opts: { identity: TrackingIdentity; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
if (!isLocalFile(input)) return input;
|
||||
return uploadFile({ apiKey, model, filePath: input, signal: opts.signal });
|
||||
return uploadFile({
|
||||
apiKey,
|
||||
model,
|
||||
filePath: input,
|
||||
identity: opts.identity,
|
||||
signal: opts.signal,
|
||||
});
|
||||
}
|
||||
|
||||
function combineWithTimeout(
|
||||
|
||||
@@ -29,9 +29,16 @@ export interface AgentTarget {
|
||||
detectDirs: string[];
|
||||
}
|
||||
|
||||
/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */
|
||||
/**
|
||||
* Computed on each call (depends on homedir / XDG_CONFIG_HOME / cwd; easy to override in tests).
|
||||
* Registry mirrors the vercel-labs/skills agent list, minus agents that cannot participate in
|
||||
* global symlink fan-out (eve: no global dir, upstream forces direct writes; promptscript:
|
||||
* project-only). Shared-dir agents (Cline/Warp/Zed/Kimi/… read ~/.agents/skills; Amp/Replit
|
||||
* read $XDG_CONFIG_HOME/agents/skills) are folded into the universal pseudo-agents' detectDirs.
|
||||
*/
|
||||
export function getAgentTargets(): AgentTarget[] {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
const simple = (id: string, displayName: string, dir: string): AgentTarget => ({
|
||||
id,
|
||||
@@ -39,29 +46,194 @@ export function getAgentTargets(): AgentTarget[] {
|
||||
skillsDir: join(home, dir, "skills"),
|
||||
detectDirs: [join(home, dir)],
|
||||
});
|
||||
/** Config base dir that can be relocated via the agent's official env var */
|
||||
const envBase = (envValue: string | undefined, fallbackDir: string): string => {
|
||||
const trimmed = envValue?.trim();
|
||||
return trimmed ? trimmed : join(home, fallbackDir);
|
||||
};
|
||||
/** Target derived from an absolute base dir (detection and skills dir stay in sync) */
|
||||
const fromBase = (id: string, displayName: string, baseDir: string): AgentTarget => ({
|
||||
id,
|
||||
displayName,
|
||||
skillsDir: join(baseDir, "skills"),
|
||||
detectDirs: [baseDir],
|
||||
});
|
||||
|
||||
// OpenClaw was renamed over time (.openclaw → .clawdbot → .moltbot): link into the first
|
||||
// home that actually exists, detect any of them
|
||||
const openclawCandidates = [".openclaw", ".clawdbot", ".moltbot"].map((dir) => join(home, dir));
|
||||
const openclawHome = openclawCandidates.find((dir) => existsSync(dir)) ?? openclawCandidates[0]!;
|
||||
|
||||
// Zed's config_dir(): XDG on Linux/macOS, %APPDATA% on Windows, Flatpak override
|
||||
const zedDetectDirs = [join(xdgConfig, "zed")];
|
||||
const zedAppData = process.env.APPDATA?.trim();
|
||||
if (zedAppData) zedDetectDirs.push(join(zedAppData, "Zed"));
|
||||
const zedFlatpakConfig = process.env.FLATPAK_XDG_CONFIG_HOME?.trim();
|
||||
if (zedFlatpakConfig) zedDetectDirs.push(join(zedFlatpakConfig, "zed"));
|
||||
|
||||
const codexHome = envBase(process.env.CODEX_HOME, ".codex");
|
||||
|
||||
return [
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.)
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by Cline, Warp, Zed,
|
||||
// Kimi Code, Dexto, Firebender, Loaf, …
|
||||
{
|
||||
id: "universal",
|
||||
displayName: "Universal (~/.agents/skills)",
|
||||
skillsDir: join(home, ".agents", "skills"),
|
||||
detectDirs: [join(home, ".agents"), join(home, ".cline")],
|
||||
detectDirs: [
|
||||
join(home, ".agents"),
|
||||
join(home, ".cline"),
|
||||
join(home, ".dexto"),
|
||||
join(home, ".firebender"),
|
||||
join(home, ".kimi-code"),
|
||||
join(home, ".kimi"),
|
||||
join(home, ".loaf"),
|
||||
join(home, ".warp"),
|
||||
...zedDetectDirs,
|
||||
],
|
||||
},
|
||||
simple("claude-code", "Claude Code", ".claude"),
|
||||
simple("openclaw", "OpenClaw", ".openclaw"),
|
||||
simple("hermes", "Hermes Agent", ".hermes"),
|
||||
// XDG variant: $XDG_CONFIG_HOME/agents/skills, shared dir read by Amp-style agents; Replit
|
||||
// also reads it and is detected project-locally via cwd/.replit
|
||||
{
|
||||
id: "universal-xdg",
|
||||
displayName: "Universal (XDG agents/skills)",
|
||||
skillsDir: join(xdgConfig, "agents", "skills"),
|
||||
detectDirs: [join(xdgConfig, "agents"), join(xdgConfig, "amp"), join(cwd, ".replit")],
|
||||
},
|
||||
simple("adal", "AdaL", ".adal"),
|
||||
simple("aider-desk", "AiderDesk", ".aider-desk"),
|
||||
simple("antigravity", "Antigravity", ".gemini/antigravity"),
|
||||
simple("antigravity-cli", "Antigravity CLI", ".gemini/antigravity-cli"),
|
||||
{
|
||||
id: "astrbot",
|
||||
displayName: "AstrBot",
|
||||
skillsDir: join(home, ".astrbot", "data", "skills"),
|
||||
detectDirs: [join(cwd, "data", "skills"), join(home, ".astrbot")],
|
||||
},
|
||||
fromBase("autohand-code", "Autohand Code CLI", envBase(process.env.AUTOHAND_HOME, ".autohand")),
|
||||
simple("augment", "Augment", ".augment"),
|
||||
simple("bob", "IBM Bob", ".bob"),
|
||||
fromBase("claude-code", "Claude Code", envBase(process.env.CLAUDE_CONFIG_DIR, ".claude")),
|
||||
simple("codearts-agent", "CodeArts Agent", ".codeartsdoer"),
|
||||
{
|
||||
id: "codebuddy",
|
||||
displayName: "CodeBuddy",
|
||||
skillsDir: join(home, ".codebuddy", "skills"),
|
||||
detectDirs: [join(cwd, ".codebuddy"), join(home, ".codebuddy")],
|
||||
},
|
||||
simple("codemaker", "Codemaker", ".codemaker"),
|
||||
simple("codestudio", "Code Studio", ".codestudio"),
|
||||
{
|
||||
id: "codex",
|
||||
displayName: "Codex",
|
||||
skillsDir: join(codexHome, "skills"),
|
||||
detectDirs: [codexHome, "/etc/codex"],
|
||||
},
|
||||
simple("command-code", "Command Code", ".commandcode"),
|
||||
{
|
||||
id: "continue",
|
||||
displayName: "Continue",
|
||||
skillsDir: join(home, ".continue", "skills"),
|
||||
detectDirs: [join(cwd, ".continue"), join(home, ".continue")],
|
||||
},
|
||||
simple("cortex", "Cortex Code", ".snowflake/cortex"),
|
||||
simple("crush", "Crush", ".config/crush"),
|
||||
simple("cursor", "Cursor", ".cursor"),
|
||||
{
|
||||
id: "deepagents",
|
||||
displayName: "Deep Agents",
|
||||
skillsDir: join(home, ".deepagents", "agent", "skills"),
|
||||
detectDirs: [join(home, ".deepagents")],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
displayName: "Devin for Terminal",
|
||||
skillsDir: join(xdgConfig, "devin", "skills"),
|
||||
detectDirs: [join(xdgConfig, "devin")],
|
||||
},
|
||||
simple("droid", "Droid", ".factory"),
|
||||
simple("forgecode", "ForgeCode", ".forge"),
|
||||
simple("gemini-cli", "Gemini CLI", ".gemini"),
|
||||
simple("github-copilot", "GitHub Copilot", ".copilot"),
|
||||
{
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
skillsDir: join(xdgConfig, "goose", "skills"),
|
||||
detectDirs: [join(xdgConfig, "goose")],
|
||||
},
|
||||
fromBase("grok", "Grok Build", envBase(process.env.GROK_HOME, ".grok")),
|
||||
fromBase("hermes", "Hermes Agent", envBase(process.env.HERMES_HOME, ".hermes")),
|
||||
simple("iflow-cli", "iFlow CLI", ".iflow"),
|
||||
simple("inference-sh", "inference.sh", ".inferencesh"),
|
||||
{
|
||||
id: "jazz",
|
||||
displayName: "Jazz",
|
||||
skillsDir: join(home, ".jazz", "skills"),
|
||||
detectDirs: [join(home, ".jazz"), join(cwd, ".jazz")],
|
||||
},
|
||||
simple("junie", "Junie", ".junie"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
{
|
||||
id: "kimchi",
|
||||
displayName: "Kimchi",
|
||||
skillsDir: join(home, ".config", "kimchi", "harness", "skills"),
|
||||
detectDirs: [join(home, ".config", "kimchi")],
|
||||
},
|
||||
simple("kiro-cli", "Kiro CLI", ".kiro"),
|
||||
simple("kode", "Kode", ".kode"),
|
||||
simple("lingma", "Lingma", ".lingma"),
|
||||
simple("mcpjam", "MCPJam", ".mcpjam"),
|
||||
{
|
||||
id: "minimax-code",
|
||||
displayName: "MiniMax Code",
|
||||
skillsDir: join(home, ".minimax", "skills"),
|
||||
detectDirs: [join(home, ".minimax"), "/Applications/MiniMax Code.app"],
|
||||
},
|
||||
fromBase("mistral-vibe", "Mistral Vibe", envBase(process.env.VIBE_HOME, ".vibe")),
|
||||
simple("moxby", "Moxby", ".moxby"),
|
||||
simple("mux", "Mux", ".mux"),
|
||||
simple("neovate", "Neovate", ".neovate"),
|
||||
{
|
||||
id: "opencode",
|
||||
displayName: "OpenCode",
|
||||
skillsDir: join(xdgConfig, "opencode", "skills"),
|
||||
detectDirs: [join(xdgConfig, "opencode")],
|
||||
},
|
||||
simple("cursor", "Cursor", ".cursor"),
|
||||
simple("codex", "Codex", ".codex"),
|
||||
simple("qwen-code", "Qwen Code", ".qwen"),
|
||||
{
|
||||
id: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
skillsDir: join(openclawHome, "skills"),
|
||||
detectDirs: openclawCandidates,
|
||||
},
|
||||
simple("openhands", "OpenHands", ".openhands"),
|
||||
simple("ona", "Ona", ".ona"),
|
||||
simple("pi", "Pi", ".pi/agent"),
|
||||
simple("pochi", "Pochi", ".pochi"),
|
||||
simple("qoder", "Qoder", ".qoder"),
|
||||
simple("qoder-cn", "Qoder CN", ".qoder-cn"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
simple("qwen-code", "Qwen Code", ".qwen"),
|
||||
simple("reasonix", "Reasonix", ".reasonix"),
|
||||
simple("rovodev", "Rovo Dev", ".rovodev"),
|
||||
simple("roo", "Roo Code", ".roo"),
|
||||
{
|
||||
id: "tabnine-cli",
|
||||
displayName: "Tabnine CLI",
|
||||
skillsDir: join(home, ".tabnine", "agent", "skills"),
|
||||
detectDirs: [join(home, ".tabnine")],
|
||||
},
|
||||
simple("terramind", "Terramind", ".terramind"),
|
||||
simple("tinycloud", "Tinycloud", ".tinycloud"),
|
||||
simple("trae", "Trae", ".trae"),
|
||||
simple("trae-cn", "Trae CN", ".trae-cn"),
|
||||
simple("windsurf", "Windsurf", ".codeium/windsurf"),
|
||||
{
|
||||
id: "zcode",
|
||||
displayName: "ZCode",
|
||||
skillsDir: join(home, ".zcode", "skills"),
|
||||
detectDirs: [join(home, ".zcode"), "/Applications/ZCode.app"],
|
||||
},
|
||||
// Zenflow reads the same ~/.zencoder/skills dir, so one target covers both
|
||||
simple("zencoder", "Zencoder", ".zencoder"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -69,13 +241,45 @@ export function detectInstalledAgents(): AgentTarget[] {
|
||||
return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir)));
|
||||
}
|
||||
|
||||
/** Path equality that respects the host filesystem's case rules (Windows is case-insensitive) */
|
||||
function samePath(left: string, right: string): boolean {
|
||||
if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase();
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/** Whether absPath is the canonical skills dir or lives inside it (case-aware on Windows) */
|
||||
function isUnderCanonicalDir(absPath: string): boolean {
|
||||
const skillsDir = getSkillsDir();
|
||||
if (process.platform === "win32") {
|
||||
const lowerPath = absPath.toLowerCase();
|
||||
const lowerDir = skillsDir.toLowerCase();
|
||||
return lowerPath === lowerDir || lowerPath.startsWith(lowerDir + sep);
|
||||
}
|
||||
return absPath === skillsDir || absPath.startsWith(skillsDir + sep);
|
||||
}
|
||||
|
||||
/** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */
|
||||
function isManagedLink(linkPath: string): boolean {
|
||||
try {
|
||||
if (!lstatSync(linkPath).isSymbolicLink()) return false;
|
||||
const target = readlinkSync(linkPath);
|
||||
const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
|
||||
return abs === getSkillsDir() || abs.startsWith(getSkillsDir() + sep);
|
||||
return isUnderCanonicalDir(abs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether linkPath is a copy-fallback artifact recorded in the lock: a real directory
|
||||
* (not a symlink) at a path this tool previously wrote when symlink creation failed
|
||||
* (typical: Windows without Developer Mode). Only recorded paths qualify — foreign
|
||||
* directories are never touched.
|
||||
*/
|
||||
function isRecordedCopy(linkPath: string, recordedLinks: string[]): boolean {
|
||||
if (!recordedLinks.some((recorded) => samePath(recorded, linkPath))) return false;
|
||||
try {
|
||||
return lstatSync(linkPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -88,15 +292,20 @@ export interface LinkResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Fixed skip reason for foreign paths; fanOutSkillToAgents keys ledger drops off this value */
|
||||
const UNMANAGED_SKIP_REASON = "existing file/dir not managed by bl skill";
|
||||
|
||||
/**
|
||||
* Fan out a skill from canonical to each agent's skills dir.
|
||||
* Stale links created by this tool are rebuilt; existing files/dirs NOT managed by this tool
|
||||
* are always skipped (never delete user content). Falls back to copy when symlink fails
|
||||
* (e.g. Windows without Developer Mode).
|
||||
* Stale links created by this tool are rebuilt; recorded copy-fallback artifacts
|
||||
* (real dirs at paths present in recordedLinks) are replaced with fresh content;
|
||||
* any other existing files/dirs are always skipped (never delete user content).
|
||||
* Falls back to copy when symlink fails (e.g. Windows without Developer Mode).
|
||||
*/
|
||||
export function linkSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): LinkResult[] {
|
||||
const target = join(getSkillsDir(), name);
|
||||
const results: LinkResult[] = [];
|
||||
@@ -111,16 +320,21 @@ export function linkSkillToAgents(
|
||||
/* does not exist */
|
||||
}
|
||||
if (existing) {
|
||||
if (!isManagedLink(linkPath)) {
|
||||
if (isManagedLink(linkPath)) {
|
||||
rmSync(linkPath);
|
||||
} else if (isRecordedCopy(linkPath, recordedLinks)) {
|
||||
// Copy-fallback artifact from a previous install → replace so updates
|
||||
// reach agents that have no symlink permission
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
} else {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: "existing file/dir not managed by bl skill",
|
||||
reason: UNMANAGED_SKIP_REASON,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rmSync(linkPath);
|
||||
}
|
||||
mkdirSync(agent.skillsDir, { recursive: true });
|
||||
try {
|
||||
@@ -143,6 +357,51 @@ export function linkSkillToAgents(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out workflow: link to agents AND compute the next lock ledger in one step.
|
||||
* Shared by bl skill add/update (fresh install and self-healing) and advisor wiki sync,
|
||||
* so every channel applies the same ledger-merge rules.
|
||||
*/
|
||||
export interface FanoutOutcome {
|
||||
results: LinkResult[];
|
||||
/** Agent ids that actually received a link/copy this run (skipped ones excluded) */
|
||||
linkedAgents: string[];
|
||||
/** Next lock links ledger; see merge rules in fanOutSkillToAgents */
|
||||
links: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out and merge the resulting paths with the previously recorded ledger:
|
||||
* - effective paths from this run are recorded;
|
||||
* - recorded paths NOT visited this run are preserved (agent uninstalled/undetected —
|
||||
* the artifact may still exist and must stay reclaimable by bl skill remove);
|
||||
* - recorded paths that failed transiently this run are preserved for the same reason;
|
||||
* - recorded paths confirmed foreign this run (unmanaged skip) are dropped — the user
|
||||
* replaced our artifact, and keeping the record would let remove delete user content.
|
||||
*/
|
||||
export function fanOutSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): FanoutOutcome {
|
||||
const results = linkSkillToAgents(name, agents, recordedLinks);
|
||||
const effective = results.filter((result) => result.mode !== "skipped");
|
||||
const effectivePaths = effective.map((result) => result.path);
|
||||
const confirmedForeign = results
|
||||
.filter((result) => result.mode === "skipped" && result.reason === UNMANAGED_SKIP_REASON)
|
||||
.map((result) => result.path);
|
||||
const preserved = recordedLinks.filter(
|
||||
(recorded) =>
|
||||
!effectivePaths.some((path) => samePath(path, recorded)) &&
|
||||
!confirmedForeign.some((path) => samePath(path, recorded)),
|
||||
);
|
||||
return {
|
||||
results,
|
||||
linkedAgents: effective.map((result) => result.agent),
|
||||
links: [...effectivePaths, ...preserved],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaim fan-out artifacts for a skill across all agent dirs.
|
||||
* Symlinks pointing to canonical are removed (including historical links not in lock,
|
||||
@@ -166,7 +425,7 @@ export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []
|
||||
rmSync(linkPath);
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} else if (recordedLinks.includes(linkPath)) {
|
||||
} else if (recordedLinks.some((recorded) => samePath(recorded, linkPath))) {
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
removed.push(linkPath);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import tar from "tar-stream";
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
export function isSafeEntryName(name: string): boolean {
|
||||
// Reject backslashes outright: on Windows path.join expands backslash-separated
|
||||
// ".." segments and a leading "\" resolves to the drive root, so such names can
|
||||
// escape the extraction dir even though they pass the "/"-based checks below.
|
||||
// The publisher always packs with "/" separators, so this never rejects legit archives.
|
||||
if (name.includes("\\") || name.includes("\0")) return false;
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@ export {
|
||||
getAgentTargets,
|
||||
detectInstalledAgents,
|
||||
linkSkillToAgents,
|
||||
fanOutSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
type AgentTarget,
|
||||
type LinkResult,
|
||||
type FanoutOutcome,
|
||||
} from "./agents.ts";
|
||||
export {
|
||||
installSkill,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts";
|
||||
import { detectInstalledAgents, fanOutSkillToAgents, type AgentTarget } from "./agents.ts";
|
||||
import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import { downloadSkillAsset } from "./registry.ts";
|
||||
@@ -112,22 +112,21 @@ export interface SkillInstallRecord {
|
||||
|
||||
/**
|
||||
* Full install workflow for one skill: install into canonical, fan out to agents, and build
|
||||
* the lock entry recording effective links. Callers decide how to persist the lock entry
|
||||
* (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
|
||||
* the lock entry recording the merged links ledger. Callers decide how to persist the lock
|
||||
* entry (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
|
||||
* recordedLinks = the skill's previously recorded fan-out paths from the lock; lets the
|
||||
* fan-out replace copy-fallback artifacts and keeps unvisited paths reclaimable.
|
||||
*/
|
||||
export async function installSkillWithFanout(
|
||||
name: string,
|
||||
entry: SkillIndexEntry,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): Promise<SkillInstallRecord> {
|
||||
await installSkill(name, entry);
|
||||
const links = linkSkillToAgents(name, agents);
|
||||
const effective = links.filter((link) => link.mode !== "skipped");
|
||||
const fanout = fanOutSkillToAgents(name, agents, recordedLinks);
|
||||
return {
|
||||
lockEntry: buildSkillLockEntry(
|
||||
entry,
|
||||
effective.map((link) => link.path),
|
||||
),
|
||||
linkedAgents: effective.map((link) => link.agent),
|
||||
lockEntry: buildSkillLockEntry(entry, fanout.links),
|
||||
linkedAgents: fanout.linkedAgents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
import { maybeSyncWikiData } from "../src/advisor/sync.ts";
|
||||
import { readSkillLock } from "../src/skills/lock.ts";
|
||||
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const CONTENT_HASH = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
/** Isolated HOME/XDG/BAILIAN_CONFIG_DIR; agent config-dir overrides cleared for determinism */
|
||||
async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
|
||||
const saved = {
|
||||
HOME: process.env.HOME,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
};
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-advisor-sync-"));
|
||||
process.env.HOME = home;
|
||||
process.env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian");
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CODEX_HOME;
|
||||
try {
|
||||
await fn(home);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub the registry fetch to serve a wiki entry with the given fingerprint */
|
||||
function stubRegistryIndex() {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
skills: {
|
||||
[WIKI_SKILL_NAME]: { contentHash: CONTENT_HASH, publishedAt: "2026-08-01 10:00:00" },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Seed what postinstall leaves behind: canonical data + expired state + lock entry WITHOUT links */
|
||||
function seedPostinstallState(configDir: string): void {
|
||||
const catalogDir = join(configDir, "skills", WIKI_SKILL_NAME);
|
||||
mkdirSync(join(catalogDir, "models"), { recursive: true });
|
||||
writeFileSync(join(catalogDir, "models", "models.jsonl"), "{}\n");
|
||||
writeFileSync(join(catalogDir, "SKILL.md"), "---\nname: wiki\ndescription: docs\n---\n");
|
||||
// State older than the 12h throttle so the hash-hit branch is reached
|
||||
writeFileSync(
|
||||
join(configDir, "wiki-sync-state.json"),
|
||||
JSON.stringify({ lastChecked: Date.now() - 13 * 3600_000, contentHash: CONTENT_HASH }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(configDir, "skills", "skill-lock.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
skills: {
|
||||
[WIKI_SKILL_NAME]: {
|
||||
contentHash: CONTENT_HASH,
|
||||
installedAt: "2026-08-01T00:00:00.000Z",
|
||||
sourceType: "oss",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("advisor sync: hash-hit backfills fan-out links missing from postinstall lock entry", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!);
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
const fetchMock = stubRegistryIndex();
|
||||
|
||||
const updated = await maybeSyncWikiData();
|
||||
|
||||
// Content unchanged → no data update, but the fan-out gap is repaired
|
||||
expect(updated).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
|
||||
expect(locked?.links).toEqual([join(home, ".claude", "skills", WIKI_SKILL_NAME)]);
|
||||
|
||||
// Second run: fresh throttle + links already recorded → no fetch, no rewrite
|
||||
await maybeSyncWikiData();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("advisor sync: hash-hit keeps an existing links array untouched", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!);
|
||||
// Upgrade the lock entry to "already fanned out" shape
|
||||
const existingLink = join(home, ".claude", "skills", WIKI_SKILL_NAME);
|
||||
const lockPath = join(process.env.BAILIAN_CONFIG_DIR!, "skills", "skill-lock.json");
|
||||
const lockContent = JSON.parse(readFileSync(lockPath, "utf-8"));
|
||||
lockContent.skills[WIKI_SKILL_NAME].links = [existingLink];
|
||||
writeFileSync(lockPath, JSON.stringify(lockContent));
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
stubRegistryIndex();
|
||||
|
||||
await maybeSyncWikiData();
|
||||
|
||||
expect(readSkillLock().skills[WIKI_SKILL_NAME]?.links).toEqual([existingLink]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import type { Identity, Settings } from "../src/index.ts";
|
||||
import { createInstrumentedFetch, SOURCE_CONFIG } from "../src/index.ts";
|
||||
import { createInstrumentedFetch, sourceConfig } from "../src/index.ts";
|
||||
|
||||
const identity: Identity = {
|
||||
binName: "bl",
|
||||
@@ -51,7 +51,12 @@ test("adds UA and tracking header on Alibaba Cloud hosts", async () => {
|
||||
{ method: "POST", headers: { Authorization: "Bearer k" } },
|
||||
);
|
||||
expect(headers.get("user-agent")).toBe("bailian-cli/1.2.3");
|
||||
expect(headers.get("x-dashscope-source-config")).toBe(SOURCE_CONFIG);
|
||||
expect(headers.get("x-dashscope-source-config")).toBe(
|
||||
JSON.stringify({
|
||||
channel: "bailian-cli",
|
||||
tags: { t1: "public", t2: "bl", t3: "1.2.3" },
|
||||
}),
|
||||
);
|
||||
expect(headers.get("authorization")).toBe("Bearer k");
|
||||
});
|
||||
|
||||
@@ -82,3 +87,12 @@ test("passes non-URL-parseable inputs through without tracking headers", async (
|
||||
expect(url).toBe("/relative/path");
|
||||
expect(headers.get("x-dashscope-source-config")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses kscli identity and version in source config", () => {
|
||||
expect(sourceConfig({ binName: "kscli", version: "1.13.1" })).toBe(
|
||||
JSON.stringify({
|
||||
channel: "bailian-cli",
|
||||
tags: { t1: "public", t2: "kscli", t3: "1.13.1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
detectInstalledAgents,
|
||||
fanOutSkillToAgents,
|
||||
getAgentTargets,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
@@ -28,11 +29,32 @@ async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
|
||||
HOME: process.env.HOME,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
VIBE_HOME: process.env.VIBE_HOME,
|
||||
HERMES_HOME: process.env.HERMES_HOME,
|
||||
AUTOHAND_HOME: process.env.AUTOHAND_HOME,
|
||||
GROK_HOME: process.env.GROK_HOME,
|
||||
APPDATA: process.env.APPDATA,
|
||||
FLATPAK_XDG_CONFIG_HOME: process.env.FLATPAK_XDG_CONFIG_HOME,
|
||||
};
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-"));
|
||||
process.env.HOME = home;
|
||||
process.env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian");
|
||||
// Agent config-dir overrides must not leak in from the dev machine
|
||||
for (const key of [
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"CODEX_HOME",
|
||||
"VIBE_HOME",
|
||||
"HERMES_HOME",
|
||||
"AUTOHAND_HOME",
|
||||
"GROK_HOME",
|
||||
"APPDATA",
|
||||
"FLATPAK_XDG_CONFIG_HOME",
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
await fn(home);
|
||||
} finally {
|
||||
@@ -52,10 +74,16 @@ function seedCanonicalSkill(name: string): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => {
|
||||
test("agents: registry mirrors upstream agent list minus non-symlinkable agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
expect(getAgentTargets().map((a) => a.id)).toContain("universal");
|
||||
expect(getAgentTargets()).toHaveLength(11);
|
||||
const ids = getAgentTargets().map((agent) => agent.id);
|
||||
expect(ids).toContain("universal");
|
||||
expect(ids).toContain("universal-xdg");
|
||||
expect(getAgentTargets()).toHaveLength(65);
|
||||
// eve (no global dir, upstream forces direct writes) and promptscript (project-only)
|
||||
// cannot participate in global symlink fan-out
|
||||
expect(ids).not.toContain("eve");
|
||||
expect(ids).not.toContain("promptscript");
|
||||
expect(detectInstalledAgents()).toEqual([]);
|
||||
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
@@ -68,6 +96,100 @@ test("agents: registry has universal + 11 agents, only detects those whose confi
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: expanded registry detects per-agent config dirs", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".roo"), { recursive: true });
|
||||
mkdirSync(join(home, ".trae"), { recursive: true });
|
||||
mkdirSync(join(home, ".gemini"), { recursive: true });
|
||||
mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
|
||||
mkdirSync(join(home, ".snowflake", "cortex"), { recursive: true });
|
||||
|
||||
const detected = detectInstalledAgents().map((agent) => agent.id);
|
||||
expect(detected).toEqual(["cortex", "gemini-cli", "roo", "trae", "windsurf"]);
|
||||
|
||||
// skills dirs follow each agent's own convention
|
||||
const targets = getAgentTargets();
|
||||
expect(targets.find((agent) => agent.id === "windsurf")?.skillsDir).toBe(
|
||||
join(home, ".codeium", "windsurf", "skills"),
|
||||
);
|
||||
expect(targets.find((agent) => agent.id === "cortex")?.skillsDir).toBe(
|
||||
join(home, ".snowflake", "cortex", "skills"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: shared-dir agents (Warp/Zed/Kimi/…) light up the universal target", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".warp"), { recursive: true });
|
||||
mkdirSync(join(home, ".config", "zed"), { recursive: true });
|
||||
|
||||
const detected = detectInstalledAgents();
|
||||
expect(detected.map((agent) => agent.id)).toEqual(["universal"]);
|
||||
expect(detected[0].skillsDir).toBe(join(home, ".agents", "skills"));
|
||||
|
||||
seedCanonicalSkill("demo");
|
||||
linkSkillToAgents("demo");
|
||||
expect(lstatSync(join(home, ".agents", "skills", "demo")).isSymbolicLink()).toBe(true);
|
||||
// No per-agent dirs were invented for shared-dir agents
|
||||
expect(existsSync(join(home, ".warp", "skills"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: Replit project marker in cwd lights up universal-xdg", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(home);
|
||||
try {
|
||||
expect(detectInstalledAgents()).toEqual([]);
|
||||
mkdirSync(join(home, ".replit"), { recursive: true });
|
||||
const detected = detectInstalledAgents();
|
||||
expect(detected.map((agent) => agent.id)).toEqual(["universal-xdg"]);
|
||||
expect(detected[0].skillsDir).toBe(join(home, ".config", "agents", "skills"));
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: OpenClaw historical alias dirs are detected and link into the existing home", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
// Only the legacy .clawdbot home exists → links must land there, not in .openclaw
|
||||
mkdirSync(join(home, ".clawdbot"), { recursive: true });
|
||||
const openclaw = detectInstalledAgents().find((agent) => agent.id === "openclaw");
|
||||
expect(openclaw?.skillsDir).toBe(join(home, ".clawdbot", "skills"));
|
||||
|
||||
seedCanonicalSkill("demo");
|
||||
linkSkillToAgents("demo");
|
||||
expect(lstatSync(join(home, ".clawdbot", "skills", "demo")).isSymbolicLink()).toBe(true);
|
||||
expect(existsSync(join(home, ".openclaw"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: VIBE_HOME/HERMES_HOME/AUTOHAND_HOME/GROK_HOME relocate their agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
const customDirs = {
|
||||
"mistral-vibe": join(home, "custom-vibe"),
|
||||
hermes: join(home, "custom-hermes"),
|
||||
"autohand-code": join(home, "custom-autohand"),
|
||||
grok: join(home, "custom-grok"),
|
||||
};
|
||||
process.env.VIBE_HOME = customDirs["mistral-vibe"];
|
||||
process.env.HERMES_HOME = customDirs.hermes;
|
||||
process.env.AUTOHAND_HOME = customDirs["autohand-code"];
|
||||
process.env.GROK_HOME = customDirs.grok;
|
||||
for (const dir of Object.values(customDirs)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const targets = getAgentTargets();
|
||||
for (const [id, baseDir] of Object.entries(customDirs)) {
|
||||
const target = targets.find((agent) => agent.id === id);
|
||||
expect(target?.skillsDir).toBe(join(baseDir, "skills"));
|
||||
expect(detectInstalledAgents().map((agent) => agent.id)).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
@@ -127,3 +249,137 @@ test("agents: unlink reclaims managed links, leaves foreign content untouched",
|
||||
expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: official config-dir env vars relocate detection and fan-out", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
const customClaude = join(home, "relocated-claude");
|
||||
const customCodex = join(home, "relocated-codex");
|
||||
mkdirSync(customClaude, { recursive: true });
|
||||
mkdirSync(customCodex, { recursive: true });
|
||||
process.env.CLAUDE_CONFIG_DIR = customClaude;
|
||||
process.env.CODEX_HOME = customCodex;
|
||||
|
||||
const targets = getAgentTargets();
|
||||
const claude = targets.find((agent) => agent.id === "claude-code");
|
||||
const codex = targets.find((agent) => agent.id === "codex");
|
||||
expect(claude?.skillsDir).toBe(join(customClaude, "skills"));
|
||||
expect(codex?.detectDirs).toEqual([customCodex, "/etc/codex"]);
|
||||
|
||||
// Detected via the relocated dirs even though default ~/.claude and ~/.codex are absent
|
||||
const detected = detectInstalledAgents().map((agent) => agent.id);
|
||||
expect(detected).toContain("claude-code");
|
||||
expect(detected).toContain("codex");
|
||||
expect(existsSync(join(home, ".claude"))).toBe(false);
|
||||
|
||||
// Fan-out lands in the relocated config dir, not the default location
|
||||
seedCanonicalSkill("demo");
|
||||
const results = linkSkillToAgents("demo");
|
||||
const claudeLink = results.find((link) => link.agent === "claude-code");
|
||||
expect(claudeLink?.path).toBe(join(customClaude, "skills", "demo"));
|
||||
expect(lstatSync(claudeLink!.path).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: Amp-style XDG config dir lights up the universal-xdg shared target", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".config", "amp"), { recursive: true });
|
||||
const xdg = detectInstalledAgents().find((agent) => agent.id === "universal-xdg");
|
||||
expect(xdg?.skillsDir).toBe(join(home, ".config", "agents", "skills"));
|
||||
|
||||
seedCanonicalSkill("demo");
|
||||
linkSkillToAgents("demo");
|
||||
const sharedLink = join(home, ".config", "agents", "skills", "demo");
|
||||
expect(lstatSync(sharedLink).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: recorded copy-fallback artifact is replaced; unrecorded dir stays skipped", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
const canonical = seedCanonicalSkill("demo");
|
||||
const copyPath = join(home, ".claude", "skills", "demo");
|
||||
|
||||
// Simulate a previous install that fell back to copy (no symlink permission, e.g. Windows)
|
||||
mkdirSync(copyPath, { recursive: true });
|
||||
writeFileSync(join(copyPath, "SKILL.md"), "stale copy");
|
||||
|
||||
// Without a lock record the dir is foreign → skipped, content untouched
|
||||
const unrecorded = linkSkillToAgents("demo");
|
||||
expect(unrecorded[0].mode).toBe("skipped");
|
||||
expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toBe("stale copy");
|
||||
|
||||
// With the recorded link the artifact is rebuilt and points at canonical again
|
||||
const recorded = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]);
|
||||
expect(recorded[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
|
||||
expect(lstatSync(copyPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(copyPath)).toBe(canonical);
|
||||
|
||||
// Subsequent runs keep refreshing through the rebuilt link
|
||||
writeFileSync(join(canonical, "SKILL.md"), "---\nname: x\ndescription: y\n---\nv2\n");
|
||||
const refreshed = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]);
|
||||
expect(refreshed[0].mode).toBe("symlink");
|
||||
expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toContain("v2");
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: recorded plain file (not a copy dir) is never replaced", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude", "skills"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
const filePath = join(home, ".claude", "skills", "demo");
|
||||
writeFileSync(filePath, "user file");
|
||||
|
||||
// Even when (erroneously) recorded, a non-directory never qualifies as a copy artifact
|
||||
const results = linkSkillToAgents("demo", detectInstalledAgents(), [filePath]);
|
||||
expect(results[0].mode).toBe("skipped");
|
||||
expect(readFileSync(filePath, "utf-8")).toBe("user file");
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: unlink removes recorded copy-fallback directories", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
const copyPath = join(home, ".claude", "skills", "demo");
|
||||
mkdirSync(copyPath, { recursive: true });
|
||||
writeFileSync(join(copyPath, "SKILL.md"), "copy");
|
||||
|
||||
const removed = unlinkSkillFromAgents("demo", [copyPath]);
|
||||
expect(removed).toEqual([copyPath]);
|
||||
expect(existsSync(copyPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("fanout: recorded path of an unvisited agent stays in the ledger", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
// Simulate a copy artifact left by an agent that is no longer detected (e.g. uninstalled
|
||||
// Qoder): its recorded path must survive the merge so bl skill remove can still reclaim it
|
||||
const orphanPath = join(home, ".qoder", "skills", "demo");
|
||||
|
||||
const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [orphanPath]);
|
||||
|
||||
expect(fanout.linkedAgents).toEqual(["claude-code"]);
|
||||
const claudeLink = join(home, ".claude", "skills", "demo");
|
||||
expect(fanout.links).toContain(claudeLink);
|
||||
expect(fanout.links).toContain(orphanPath);
|
||||
});
|
||||
});
|
||||
|
||||
test("fanout: recorded path confirmed foreign this run is dropped from the ledger", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude", "skills"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
// User replaced our artifact with their own plain file → scanned, skipped as unmanaged;
|
||||
// keeping the record would let bl skill remove delete user content
|
||||
const foreignPath = join(home, ".claude", "skills", "demo");
|
||||
writeFileSync(foreignPath, "user file");
|
||||
|
||||
const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [foreignPath]);
|
||||
|
||||
expect(fanout.linkedAgents).toEqual([]);
|
||||
expect(fanout.links).not.toContain(foreignPath);
|
||||
expect(readFileSync(foreignPath, "utf-8")).toBe("user file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { brotliCompressSync } from "zlib";
|
||||
import tar from "tar-stream";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { installSkillFromBuffer } from "../src/skills/installer.ts";
|
||||
import type { AgentTarget } from "../src/skills/agents.ts";
|
||||
import { isSafeEntryName } from "../src/skills/extract.ts";
|
||||
import { installSkillFromBuffer, installSkillWithFanout } from "../src/skills/installer.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
@@ -82,6 +84,31 @@ test("installer: tar-slip entry → rejected and canonical not written", async (
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: backslash entry names rejected (Windows tar-slip vector)", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({
|
||||
"SKILL.md": VALID_SKILL_MD,
|
||||
"foo\\..\\evil.txt": "pwned\n",
|
||||
});
|
||||
await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("extract: entry name safety rules", async () => {
|
||||
expect(isSafeEntryName("SKILL.md")).toBe(true);
|
||||
expect(isSafeEntryName("references/usage.md")).toBe(true);
|
||||
expect(isSafeEntryName("../evil")).toBe(false);
|
||||
expect(isSafeEntryName("a/../../evil")).toBe(false);
|
||||
expect(isSafeEntryName("/abs/path")).toBe(false);
|
||||
expect(isSafeEntryName("C:/windows")).toBe(false);
|
||||
// Backslashes: drive-root escape and "\.." expansion on Windows
|
||||
expect(isSafeEntryName("foo\\bar")).toBe(false);
|
||||
expect(isSafeEntryName("\\evil")).toBe(false);
|
||||
expect(isSafeEntryName("foo\\..\\evil")).toBe(false);
|
||||
expect(isSafeEntryName("nul\0byte")).toBe(false);
|
||||
});
|
||||
|
||||
test("installer: SKILL.md validation fails → previously installed version preserved as-is", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD }));
|
||||
@@ -136,3 +163,77 @@ test("installer: contentHash mismatch → rejected, previous install preserved",
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- installSkillWithFanout: download + install + fan-out + lock entry in one workflow ----
|
||||
|
||||
/** Stub global fetch to serve the given archive for any asset URL */
|
||||
function stubAssetDownload(tarBrBuffer: Buffer): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () =>
|
||||
tarBrBuffer.buffer.slice(
|
||||
tarBrBuffer.byteOffset,
|
||||
tarBrBuffer.byteOffset + tarBrBuffer.byteLength,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fake agent whose skills dir lives inside the temp config dir (never touches real HOME) */
|
||||
function fakeAgent(id: string, baseDir: string): AgentTarget {
|
||||
return {
|
||||
id,
|
||||
displayName: id,
|
||||
skillsDir: join(baseDir, id, "skills"),
|
||||
detectDirs: [join(baseDir, id)],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("fanout install: downloads, links agents, and builds lock entry with merged ledger", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const configDir = process.env.BAILIAN_CONFIG_DIR!;
|
||||
const files = { "SKILL.md": VALID_SKILL_MD };
|
||||
stubAssetDownload(await buildTarBr(files));
|
||||
const agent = fakeAgent("claude-code", configDir);
|
||||
// Recorded path of an agent absent from this run: must survive into the merged ledger
|
||||
const orphanPath = join(configDir, "gone-agent", "skills", "demo");
|
||||
|
||||
const record = await installSkillWithFanout(
|
||||
"demo",
|
||||
{ contentHash: expectedHashOf(files), publishedAt: "2026-08-01 10:00:00" },
|
||||
[agent],
|
||||
[orphanPath],
|
||||
);
|
||||
|
||||
expect(record.linkedAgents).toEqual(["claude-code"]);
|
||||
const linkPath = join(agent.skillsDir, "demo");
|
||||
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(record.lockEntry).toMatchObject({
|
||||
contentHash: expectedHashOf(files),
|
||||
publishedAt: "2026-08-01 10:00:00",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(record.lockEntry.links).toContain(linkPath);
|
||||
expect(record.lockEntry.links).toContain(orphanPath);
|
||||
});
|
||||
});
|
||||
|
||||
test("fanout install: download failure surfaces as BailianError and leaves no canonical dir", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 404 })),
|
||||
);
|
||||
await expect(
|
||||
installSkillWithFanout("demo", { contentHash: "sha256:whatever" }, []),
|
||||
).rejects.toThrow(BailianError);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createWriteStream, mkdirSync, unlinkSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { BailianError, ExitCode, trackingHeaders } from "bailian-cli-core";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { createProgressBar } from "../output/progress.ts";
|
||||
import type { ReadableStreamReadResult } from "stream/web";
|
||||
|
||||
@@ -9,9 +9,7 @@ export async function downloadFile(
|
||||
destPath: string,
|
||||
opts?: { quiet?: boolean },
|
||||
): Promise<{ size: number }> {
|
||||
const res = await fetch(url, {
|
||||
headers: trackingHeaders(),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new BailianError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
getConfigDir,
|
||||
trackingHeaders,
|
||||
getUpdateInstallMethod,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
@@ -165,10 +164,7 @@ export async function fetchLatestVersion(
|
||||
try {
|
||||
const encoded = npmPackage.replace("/", "%2f");
|
||||
const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...trackingHeaders(),
|
||||
},
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
Reference in New Issue
Block a user