Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
skillUpdate,
skillRemove,
skillList,
skillInit,
managedAgentInit,
managedAgentValidate,
managedAgentPlan,
Expand Down Expand Up @@ -211,6 +212,7 @@ export const commands: Record<string, AnyCommand> = {
"skill update": skillUpdate,
"skill remove": skillRemove,
"skill list": skillList,
"skill init": skillInit,
"managed-agent init": managedAgentInit,
"managed-agent validate": managedAgentValidate,
"managed-agent plan": managedAgentPlan,
Expand Down
26 changes: 17 additions & 9 deletions packages/commands/src/commands/skill/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
Expand All @@ -28,22 +27,31 @@ const INSTALL_CONCURRENCY = 3;
export default defineCommand({
description: "Install skills from the Bailian skill registry into local agents",
auth: "none",
usageArgs: "--name <all|name,...>",
usageArgs: "--all | --name <name,...>",
flags: {
all: {
type: "switch",
description: "Install all skills from the registry",
},
name: {
type: "string",
valueHint: "<all|name,...>",
description: "Skills to install: all or comma-separated skill names",
required: true,
valueHint: "<name,...>",
description: "Comma-separated skill names to install",
},
},
exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"],
validate(flags) {
if (flags.all && flags.name) return "Use either --all or --name, not both";
if (!flags.all && !flags.name)
return "Specify --all to install everything or --name <name,...> for specific skills";
return undefined;
},
exampleArgs: ["--all", "--name spark-video,bailian-model-recommend"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, false);
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const index = await fetchSkillsIndex();
const remoteNames = Object.keys(index.skills);
const names = requested === "all" ? remoteNames : requested;
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
const names = parsed === "all" ? remoteNames : parsed;

const lock = readSkillLock();
const agents = detectInstalledAgents();
Expand Down
107 changes: 107 additions & 0 deletions packages/commands/src/commands/skill/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
BailianError,
ExitCode,
defineCommand,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";

interface InitOutcome {
name: string;
status: "installed" | "failed";
publishedAt?: string;
agents?: string[];
reason?: string;
}

/** Prefix used to identify first-party Bailian skills in the registry. */
const BAILIAN_PREFIX = "bailian-";

/** Max number of skills downloading/installing at the same time. */
const INIT_CONCURRENCY = 3;

export default defineCommand({
description: "Install all bailian-* skills (one-shot bootstrap for new environments)",
auth: "none",
usageArgs: "",
exampleArgs: [""],
notes: [
"Fetches the registry index and installs every skill whose name starts with bailian-",
"Equivalent to: bl skill add --all (filtered to bailian-* skills)",
],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const index = await fetchSkillsIndex();

// Discover all bailian-* skills from the live registry index
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));

const lock = readSkillLock();
const agents = detectInstalledAgents();

const tasks = names.map((name) => async (): Promise<InitOutcome> => {
const entry = index.skills[name];
try {
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return {
name,
status: "installed",
publishedAt: entry.publishedAt,
agents: record.linkedAgents,
};
} catch (err) {
return {
name,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
};
}
});
const results = await runWithConcurrency(tasks, INIT_CONCURRENCY);
writeSkillLock(lock);

if (format === "json") {
emitResult(
{
registry: getSkillRegistryBaseUrl(),
agents: agents.map((agent) => agent.id),
skills: results,
},
format,
);
} else if (results.length === 0) {
emitBare("No bailian-* skills found in the registry.");
} else {
const rows = results.map((result) => [
result.name,
result.status,
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"),
]);
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
emitBare(line);
}
}

const failed = results.filter((result) => result.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to install`,
ExitCode.GENERAL,
"Check the reason for failed skills in the output; network failures can be retried with bl skill init",
);
}
},
});
3 changes: 1 addition & 2 deletions packages/commands/src/commands/skill/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
computeSkillStatuses,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
Expand All @@ -24,7 +23,7 @@ export default defineCommand({
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
const index = await fetchSkillsIndex();
const lock = readSkillLock();
Expand Down
3 changes: 1 addition & 2 deletions packages/commands/src/commands/skill/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
listSkillDirsOnDisk,
parseSkillNames,
readSkillLock,
Expand Down Expand Up @@ -34,7 +33,7 @@ export default defineCommand({
exampleArgs: ["--name spark-video", "--name all"],
async run(ctx) {
// Purely local operation: no remote access, works offline
const format = detectOutputFormat(ctx.settings.output);
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const requested = parseSkillNames(ctx.flags.name, false);
const lock = readSkillLock();
const names = requested === "all" ? Object.keys(lock.skills) : requested;
Expand Down
23 changes: 15 additions & 8 deletions packages/commands/src/commands/skill/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fanOutSkillToAgents,
fetchSkillsIndex,
Expand All @@ -29,19 +28,27 @@ const UPDATE_CONCURRENCY = 3;
export default defineCommand({
description: "Update installed skills to the latest registry versions",
auth: "none",
usageArgs: "[--name <all|name,...>]",
usageArgs: "[--all] [--name <name,...>]",
flags: {
all: {
type: "switch",
description: "Update all installed skills (default when neither --all nor --name is given)",
},
name: {
type: "string",
valueHint: "<all|name,...>",
description:
"Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)",
valueHint: "<name,...>",
description: "Comma-separated skill names to update (must be already installed)",
},
},
exampleArgs: ["", "--name spark-video"],
validate(flags) {
if (flags.all && flags.name) return "Use either --all or --name, not both";
return undefined;
},
exampleArgs: ["", "--all", "--name spark-video"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, true);
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const updateAll = ctx.flags.all || !ctx.flags.name;
const requested = updateAll ? "all" : parseSkillNames(ctx.flags.name, false);
const index = await fetchSkillsIndex();
const lock = readSkillLock();
const disk = new Set(listSkillDirsOnDisk());
Expand Down
1 change: 1 addition & 0 deletions packages/commands/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,4 @@ export { default as skillAdd } from "./commands/skill/add.ts";
export { default as skillUpdate } from "./commands/skill/update.ts";
export { default as skillRemove } from "./commands/skill/remove.ts";
export { default as skillList } from "./commands/skill/list.ts";
export { default as skillInit } from "./commands/skill/init.ts";
30 changes: 19 additions & 11 deletions packages/commands/tests/e2e/skill.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ describe("e2e: skill", () => {
test("skill add --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--all/);
expect(stderr).toMatch(/--name/);
});

test("skill update --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--all/);
expect(stderr).toMatch(/--name/);
});

Expand All @@ -37,41 +39,47 @@ describe("e2e: skill", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|registry/i);
});

test("skill init --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "init", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/bailian/i);
});
});

// Local-only cases: auth "none" + validation happens before any network access, no gating needed
describe("e2e: skill (local, no credentials)", () => {
test("skill add without --name errors as usage error (2)", async () => {
test("skill add without --all or --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
expect(`${stdout}\n${stderr}`).toMatch(/--all|--name|Usage:/i);
});

test("skill remove without --name errors as usage error (2)", async () => {
test("skill add with both --all and --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"remove",
"add",
"--all",
"--name",
"spark-video",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
expect(`${stdout}\n${stderr}`).toMatch(/--all|--name|either/i);
});

test("skill add rejects mixing all with specific names (2)", async () => {
// parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe
test("skill remove without --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--name",
"all,spark-video",
"remove",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/all/i);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
});

test("skill remove of a not-installed skill fails with reason (1)", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/commands/tests/e2e/topic-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export const SKILL_ROUTES: E2eRouteExports = {
"skill update": "skillUpdate",
"skill remove": "skillRemove",
"skill list": "skillList",
"skill init": "skillInit",
};

export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
Expand Down
3 changes: 2 additions & 1 deletion skills/bailian-cli/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Use this index for the skill-scoped quick index and global flags.
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill init` | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
Expand Down Expand Up @@ -86,7 +87,7 @@ Use this index for the skill-scoped quick index and global flags.
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
Expand Down
Loading