teamai-cli 0.21.0-beta.3 → 0.21.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -22,6 +22,10 @@ All notable changes to this project will be documented in this file. See [standa
22
22
  - `teamai skill` 或 `teamai skill list`:等价 `teamai list skills --source all`
23
23
  - `teamai skill show <name>`:查看单个 skill 的来源标签、命名空间、贡献者、`tags.yaml` 中的 tags、已安装的 agent 列表,以及 frontmatter 中的描述(最多 160 字符)。不渲染完整 SKILL.md 正文
24
24
 
25
+ ### 🐛 修复
26
+
27
+ - **项目级 source 配置持久化**(#335):`teamai pull` 自动上报 usage 时不再丢弃 `source add`/`remove` 尚未提交的 `teamai.yaml` 改动,后续 pull 可正常部署订阅 skills 并写入安装清单
28
+
25
29
  ## [0.14.2] (2026-04-16)
26
30
 
27
31
  ### 🐛 修复
package/README.md CHANGED
@@ -159,7 +159,8 @@ teamai source browse other-team # browse available skills
159
159
  teamai source remove other-team
160
160
  ```
161
161
 
162
- Subscribed skills sync automatically on `teamai pull`.
162
+ The add/remove change takes effect locally right away, and subscribed skills sync on the next
163
+ `teamai pull`. Run `teamai push` when you want to share the `teamai.yaml` change with teammates.
163
164
 
164
165
  ## Knowledge Base
165
166
 
package/README.zh-CN.md CHANGED
@@ -159,7 +159,8 @@ teamai source browse other-team # 浏览可用 skills
159
159
  teamai source remove other-team
160
160
  ```
161
161
 
162
- 订阅的 skills `teamai pull` 时自动同步。
162
+ 添加/移除会立即在本机生效,订阅的 skills 会在下一次 `teamai pull` 时同步。需要将
163
+ `teamai.yaml` 的改动分享给团队成员时,再运行 `teamai push`。
163
164
 
164
165
  ## 知识库
165
166
 
package/dist/index.js CHANGED
@@ -3918,6 +3918,13 @@ function assertSafePath(target, allowedRoots) {
3918
3918
  `Path traversal detected: "${target}" is outside allowed directories: ${allowedRoots.join(", ")}`
3919
3919
  );
3920
3920
  }
3921
+ function assertWithinRoot(root, candidate, message) {
3922
+ const resolvedRoot = path11.resolve(root);
3923
+ const resolvedCandidate = path11.resolve(candidate);
3924
+ if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + path11.sep)) {
3925
+ throw new Error(message ?? `path traversal detected: "${candidate}" is outside "${root}"`);
3926
+ }
3927
+ }
3921
3928
  function resolveReal(p) {
3922
3929
  const expanded = p.startsWith("~") ? path11.join(os4.homedir(), p.slice(1)) : p;
3923
3930
  const abs = path11.resolve(expanded);
@@ -8712,12 +8719,6 @@ async function resolveSkillNamespaces(localConfig) {
8712
8719
  return [localConfig.primaryRole, ...localConfig.additionalRoles ?? []];
8713
8720
  }
8714
8721
  }
8715
- function getSkillDestination(localConfig, skillName, namespace) {
8716
- if (namespace) {
8717
- return path24.join(localConfig.repo.localPath, "skills", namespace, skillName);
8718
- }
8719
- return path24.join(localConfig.repo.localPath, "skills", skillName);
8720
- }
8721
8722
  async function scanSkillsRecursively(dirPath) {
8722
8723
  const results = /* @__PURE__ */ new Map();
8723
8724
  async function walk2(currentPath) {
@@ -8754,6 +8755,7 @@ var init_skills = __esm({
8754
8755
  init_builtin_skills();
8755
8756
  init_openclaw_hooks();
8756
8757
  init_roles();
8758
+ init_path_safety();
8757
8759
  CONTRIBUTORS_FILE = "CONTRIBUTORS";
8758
8760
  SKILL_MD = "SKILL.md";
8759
8761
  FRONTMATTER_REGEX = /^---\n[\s\S]*?\n---/;
@@ -8917,7 +8919,13 @@ var init_skills = __esm({
8917
8919
  * Copy a local skill to the team repo.
8918
8920
  */
8919
8921
  async pushItem(item, _teamConfig, localConfig) {
8920
- const dest = getSkillDestination(localConfig, item.name, item.namespace ?? localConfig.primaryRole);
8922
+ const skillsRoot = path24.join(localConfig.repo.localPath, "skills");
8923
+ const dest = path24.resolve(localConfig.repo.localPath, item.relativePath);
8924
+ assertWithinRoot(
8925
+ skillsRoot,
8926
+ dest,
8927
+ `Invalid skill destination outside team repo skills directory: ${item.relativePath}`
8928
+ );
8921
8929
  await copyDir(item.sourcePath, dest);
8922
8930
  log.debug(`Copied skill ${item.name} \u2192 team repo`);
8923
8931
  await ensureSkillFrontmatter(dest, item.name);
@@ -11159,6 +11167,7 @@ async function push(options) {
11159
11167
  } else {
11160
11168
  log.error(`Push failed: ${e.message}`);
11161
11169
  }
11170
+ process.exitCode = 1;
11162
11171
  }
11163
11172
  return;
11164
11173
  }
@@ -11175,6 +11184,7 @@ async function pushCore(localConfig, teamConfig, options) {
11175
11184
  const git = createGit2(repoPath);
11176
11185
  if (!await isDedicatedRepoRoot(repoPath)) {
11177
11186
  pullSpin.fail("Cannot push: team repo path is not a dedicated git root. Run `teamai init` to re-clone the team repo before pushing.");
11187
+ process.exitCode = 1;
11178
11188
  return;
11179
11189
  }
11180
11190
  const yamlPath = path35.join(repoPath, "teamai.yaml");
@@ -11295,6 +11305,25 @@ async function pushCore(localConfig, teamConfig, options) {
11295
11305
  allItems.length = 0;
11296
11306
  allItems.push(matchedItem);
11297
11307
  }
11308
+ if (options.role) {
11309
+ try {
11310
+ assertSafeResourceName(options.role);
11311
+ for (const item of allItems) {
11312
+ if (item.type === "skills") {
11313
+ assertSafeResourceName(item.name);
11314
+ }
11315
+ }
11316
+ } catch (e) {
11317
+ log.error(`Invalid skill role or name: ${e.message}`);
11318
+ process.exitCode = 2;
11319
+ return;
11320
+ }
11321
+ for (const item of allItems) {
11322
+ if (item.type !== "skills") continue;
11323
+ item.namespace = options.role;
11324
+ item.relativePath = `skills/${options.role}/${item.name}`;
11325
+ }
11326
+ }
11298
11327
  if (allItems.length === 0) {
11299
11328
  if (pendingTeamConfig !== null) {
11300
11329
  await pushTeamConfigOnly(localConfig, teamConfig, options);
@@ -11452,7 +11481,7 @@ async function pushCore(localConfig, teamConfig, options) {
11452
11481
  return;
11453
11482
  }
11454
11483
  pushSpin.succeed(`Pushed branch ${branchName}`);
11455
- await createPrWithFallback(
11484
+ const prUrl = await createPrWithFallback(
11456
11485
  teamConfig,
11457
11486
  localConfig,
11458
11487
  branchName,
@@ -11460,6 +11489,9 @@ async function pushCore(localConfig, teamConfig, options) {
11460
11489
  `Pushed ${selectedItems.length} resource(s):
11461
11490
  ${selectedItems.map((i) => `- [${i.type}] ${i.name}`).join("\n")}`
11462
11491
  );
11492
+ if (!prUrl) {
11493
+ process.exitCode = 1;
11494
+ }
11463
11495
  await checkoutMaster(localConfig.repo.localPath);
11464
11496
  } catch (e) {
11465
11497
  pushSpin.fail(`Push failed: ${e.message}`);
@@ -11475,6 +11507,7 @@ ${selectedItems.map((i) => `- [${i.type}] ${i.name}`).join("\n")}`
11475
11507
  );
11476
11508
  }
11477
11509
  }
11510
+ process.exitCode = 1;
11478
11511
  return;
11479
11512
  }
11480
11513
  const state = await loadStateForScope(localConfig.scope, localConfig.projectRoot);
@@ -11516,16 +11549,20 @@ async function pushTeamConfigOnly(localConfig, teamConfig, options) {
11516
11549
  return;
11517
11550
  }
11518
11551
  pushSpin.succeed(`Pushed branch ${branchName}`);
11519
- await createPrWithFallback(
11552
+ const prUrl = await createPrWithFallback(
11520
11553
  teamConfig,
11521
11554
  localConfig,
11522
11555
  branchName,
11523
11556
  commitMsg,
11524
11557
  "Updated team config (teamai.yaml)"
11525
11558
  );
11559
+ if (!prUrl) {
11560
+ process.exitCode = 1;
11561
+ }
11526
11562
  await checkoutMaster(localConfig.repo.localPath);
11527
11563
  } catch (e) {
11528
11564
  pushSpin.fail(`Push failed: ${e.message}`);
11565
+ process.exitCode = 1;
11529
11566
  return;
11530
11567
  }
11531
11568
  }
@@ -11784,6 +11821,8 @@ async function pushRepoBranch(localPath, message, files, branchName) {
11784
11821
  await git.add(files);
11785
11822
  const status2 = await git.status();
11786
11823
  if (status2.staged.length === 0) {
11824
+ await git.reset(["--hard", "HEAD"]);
11825
+ await git.clean("f", ["-d"]);
11787
11826
  const defaultBranch = await getDefaultBranch(localPath);
11788
11827
  log.debug(`Nothing to commit, switching back to ${defaultBranch}`);
11789
11828
  await switchToDefaultBranch(git, defaultBranch);
@@ -11792,6 +11831,8 @@ async function pushRepoBranch(localPath, message, files, branchName) {
11792
11831
  }
11793
11832
  const diffOutput = await git.diff(["--cached", "--unified=0"]);
11794
11833
  if (isMetadataOnlyDiff(diffOutput)) {
11834
+ await git.reset(["--hard", "HEAD"]);
11835
+ await git.clean("f", ["-d"]);
11795
11836
  const defaultBranch = await getDefaultBranch(localPath);
11796
11837
  log.debug(`Only metadata/timestamp changes detected, switching back to ${defaultBranch}`);
11797
11838
  await switchToDefaultBranch(git, defaultBranch);
@@ -15740,8 +15781,18 @@ async function reportUsageToTeam(repoPath, username, options) {
15740
15781
  log.debug(`Skipping report: ${repoPath} is not a dedicated team-repo root (safety guard)`);
15741
15782
  return;
15742
15783
  }
15743
- await resetToCleanMaster(git, repoPath);
15744
- await pullRepo(repoPath);
15784
+ const yamlPath = path48.join(repoPath, "teamai.yaml");
15785
+ const workingContent = await readFileSafe(yamlPath);
15786
+ const committedContent = workingContent === null ? null : await getFileContentAtRev(repoPath, "HEAD", "teamai.yaml");
15787
+ const pendingTeamConfig = workingContent !== null && (committedContent === null || committedContent.toString() !== workingContent) ? workingContent : null;
15788
+ try {
15789
+ await resetToCleanMaster(git, repoPath);
15790
+ await pullRepo(repoPath);
15791
+ } finally {
15792
+ if (pendingTeamConfig !== null) {
15793
+ await writeFile(yamlPath, pendingTeamConfig);
15794
+ }
15795
+ }
15745
15796
  }
15746
15797
  if (hasUsage || hasInterventions || hasPromptTokens) {
15747
15798
  const statsDir = path48.join(writeRoot, "stats");
@@ -16460,7 +16511,7 @@ async function scanRoleAwareSkills(localConfig, namespaces) {
16460
16511
  }
16461
16512
  return [...items.values()];
16462
16513
  }
16463
- async function cleanupInactiveNamespaceSkills(teamConfig, localConfig, activeSkillNames, inactiveSkillNames) {
16514
+ async function cleanupInactiveNamespaceSkills(teamConfig, localConfig, retainedSkillNames, inactiveSkillNames) {
16464
16515
  const baseDir = resolveBaseDir(localConfig);
16465
16516
  for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) {
16466
16517
  if (isAgentDisabled(localConfig, tool)) continue;
@@ -16470,7 +16521,7 @@ async function cleanupInactiveNamespaceSkills(teamConfig, localConfig, activeSki
16470
16521
  const localSkillNames = await listDirs(path50.join(baseDir, toolPath.skills));
16471
16522
  for (const skillName of localSkillNames) {
16472
16523
  if (BUILTIN_SKILL_NAMES.has(skillName)) continue;
16473
- if (activeSkillNames.has(skillName)) continue;
16524
+ if (retainedSkillNames.has(skillName)) continue;
16474
16525
  if (!inactiveSkillNames.has(skillName)) continue;
16475
16526
  const localSkillDir = path50.join(baseDir, toolPath.skills, skillName);
16476
16527
  await remove(localSkillDir);
@@ -16618,7 +16669,11 @@ async function pullForScope(localConfig, options, policy = {}) {
16618
16669
  let tagIncluded = [];
16619
16670
  if (hasActiveTagSubscriptions) {
16620
16671
  const tagResult = filterByTags(allTeamSkills, tagsConfig, subscribedTags, "skills");
16621
- tagIncluded = tagResult.included;
16672
+ const subscribedTagSet = new Set(subscribedTags);
16673
+ tagIncluded = tagResult.included.filter((item) => {
16674
+ const itemTags = tagsConfig.skills[item.name];
16675
+ return itemTags?.some((tag) => subscribedTagSet.has(tag));
16676
+ });
16622
16677
  skippedByTags = tagResult.skipped.length;
16623
16678
  }
16624
16679
  const merged = /* @__PURE__ */ new Map();
@@ -16718,7 +16773,7 @@ async function pullForScope(localConfig, options, policy = {}) {
16718
16773
  await cleanupInactiveNamespaceSkills(
16719
16774
  freshConfig,
16720
16775
  localConfig,
16721
- roleContext.activeSkillNames,
16776
+ desiredSkillNames ?? roleContext.activeSkillNames,
16722
16777
  roleContext.inactiveSkillNames
16723
16778
  );
16724
16779
  }
@@ -18963,6 +19018,14 @@ async function collectTeamRuleNames(repoPath) {
18963
19018
  files.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
18964
19019
  );
18965
19020
  }
19021
+ async function collectTeamAgentNames(repoPath) {
19022
+ const teamAgentsDir = path58.join(repoPath, "agents");
19023
+ if (!await pathExists(teamAgentsDir)) return /* @__PURE__ */ new Set();
19024
+ const files = await listFiles(teamAgentsDir);
19025
+ return new Set(
19026
+ files.filter((file) => file.endsWith(".yaml") || file.endsWith(".md")).map((file) => path58.basename(file).replace(/\.(yaml|md)$/, ""))
19027
+ );
19028
+ }
18966
19029
  function isEmptyHooksResidue(parsed) {
18967
19030
  if (parsed == null || !("hooks" in parsed) || typeof parsed.hooks !== "object" || parsed.hooks == null) return false;
18968
19031
  const entries = Object.values(parsed.hooks);
@@ -18976,7 +19039,7 @@ function opencodePluginTargets(baseDir, scope) {
18976
19039
  }
18977
19040
  return targets;
18978
19041
  }
18979
- async function discoverToolResources(tool, toolPath, baseDir, teamSkillNames, teamRuleNames, managedHooksPath, scope) {
19042
+ async function discoverToolResources(tool, toolPath, baseDir, teamSkillNames, teamRuleNames, teamAgentNames, managedHooksPath, scope) {
18980
19043
  const res = {
18981
19044
  hookFiles: [],
18982
19045
  openclawHookDirs: [],
@@ -19022,12 +19085,18 @@ async function discoverToolResources(tool, toolPath, baseDir, teamSkillNames, te
19022
19085
  }
19023
19086
  }
19024
19087
  if (toolPath.skills) {
19025
- const skillsDir = path58.join(baseDir, toolPath.skills);
19026
- if (await pathExists(skillsDir)) {
19027
- const dirs = await listDirs(skillsDir);
19028
- for (const dir of dirs) {
19029
- if (teamSkillNames.has(dir)) {
19030
- res.skillDirs.push(path58.join(skillsDir, dir));
19088
+ const skillRoots = /* @__PURE__ */ new Set([path58.join(baseDir, toolPath.skills)]);
19089
+ if (tool === "openclaw") {
19090
+ const workspaceDir = await resolveOpenclawWorkspaceDir();
19091
+ if (workspaceDir) skillRoots.add(path58.join(workspaceDir, "skills"));
19092
+ }
19093
+ for (const skillsDir of skillRoots) {
19094
+ if (await pathExists(skillsDir)) {
19095
+ const dirs = await listDirs(skillsDir);
19096
+ for (const dir of dirs) {
19097
+ if (teamSkillNames.has(dir)) {
19098
+ res.skillDirs.push(path58.join(skillsDir, dir));
19099
+ }
19031
19100
  }
19032
19101
  }
19033
19102
  }
@@ -19048,11 +19117,11 @@ async function discoverToolResources(tool, toolPath, baseDir, teamSkillNames, te
19048
19117
  if (toolPath.agents) {
19049
19118
  const agentsDir = path58.join(baseDir, toolPath.agents);
19050
19119
  if (await pathExists(agentsDir)) {
19051
- for (const name of BUILTIN_AGENT_NAMES) {
19052
- const agentFile = path58.join(agentsDir, `${name}.md`);
19053
- if (await pathExists(agentFile)) {
19054
- res.agentFiles.push(agentFile);
19055
- }
19120
+ for (const file of await listFiles(agentsDir)) {
19121
+ if (!file.endsWith(".md") && !file.endsWith(".toml")) continue;
19122
+ const name = path58.basename(file).replace(/\.(md|toml)$/, "");
19123
+ if (!teamAgentNames.has(name) && !BUILTIN_AGENT_NAMES.has(name)) continue;
19124
+ res.agentFiles.push(path58.join(agentsDir, file));
19056
19125
  }
19057
19126
  }
19058
19127
  }
@@ -19066,6 +19135,7 @@ async function buildRemovalPlan(localConfig, teamConfig, agentFilter) {
19066
19135
  for (const name of BUILTIN_SKILL_NAMES) teamSkillNames.add(name);
19067
19136
  const teamRuleNames = await collectTeamRuleNames(repoPath);
19068
19137
  for (const name of BUILTIN_RULE_NAMES) teamRuleNames.add(name);
19138
+ const teamAgentNames = await collectTeamAgentNames(repoPath);
19069
19139
  const localAgentManifestPath = path58.join(
19070
19140
  getUserHome(),
19071
19141
  ".teamai",
@@ -19090,7 +19160,16 @@ async function buildRemovalPlan(localConfig, teamConfig, agentFilter) {
19090
19160
  for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) {
19091
19161
  perTool.set(
19092
19162
  tool,
19093
- await discoverToolResources(tool, toolPath, baseDir, teamSkillNames, teamRuleNames, managedHooksPath, localConfig.scope)
19163
+ await discoverToolResources(
19164
+ tool,
19165
+ toolPath,
19166
+ baseDir,
19167
+ teamSkillNames,
19168
+ teamRuleNames,
19169
+ teamAgentNames,
19170
+ managedHooksPath,
19171
+ localConfig.scope
19172
+ )
19094
19173
  );
19095
19174
  }
19096
19175
  let includeShared;
@@ -19207,7 +19286,10 @@ function printSummary(plan, agentFilter) {
19207
19286
  console.log("");
19208
19287
  }
19209
19288
  if (plan.skillDirs.length > 0) {
19210
- console.log(` Skills (${plan.skillDirs.length} directories)`);
19289
+ console.log(` Skills (${plan.skillDirs.length} directories):`);
19290
+ for (const skillDir of plan.skillDirs) {
19291
+ console.log(` ${skillDir}`);
19292
+ }
19211
19293
  console.log("");
19212
19294
  }
19213
19295
  if (plan.ruleFiles.length > 0) {
@@ -19215,7 +19297,10 @@ function printSummary(plan, agentFilter) {
19215
19297
  console.log("");
19216
19298
  }
19217
19299
  if (plan.agentFiles.length > 0) {
19218
- console.log(` Agents (${plan.agentFiles.length} files)`);
19300
+ console.log(` Agents (${plan.agentFiles.length} files):`);
19301
+ for (const agentFile of plan.agentFiles) {
19302
+ console.log(` ${agentFile}`);
19303
+ }
19219
19304
  console.log("");
19220
19305
  }
19221
19306
  if (plan.mcpServers.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamai-cli",
3
- "version": "0.21.0-beta.3",
3
+ "version": "0.21.0-beta.8",
4
4
  "description": "TeamAI — the team harness for AI agents (skill sync + shared knowledge base, powered by Git)",
5
5
  "type": "module",
6
6
  "bin": {