forked from farhoodlabs/paperclip
9eac727cf1
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies through company-scoped control-plane workflows. > - Agents need reusable, inspectable skills that can be installed, reset, audited, exported, and assigned without bespoke local setup. > - The existing skill truth model needed cleanup so bundled skills, optional catalog skills, runtime skills, and adapter-provided skills have clear provenance. > - Operators also need a practical CLI and board UI for discovering and managing company skills. > - This pull request adds the skills CLI, packaged skills catalog, company skills APIs, and catalog-aware board UI. > - The benefit is a more reusable Paperclip company setup where skills are portable, auditable, and easier for operators and agents to manage. ## What Changed - Added `paperclipai skills` CLI commands and coverage for catalog listing, installing, resetting, and inspecting company skills. - Added a packaged `@paperclipai/skills-catalog` workspace with bundled and optional skill content plus validation/build tests. - Added shared company-skill types and validators used across CLI, server, and UI contracts. - Added server catalog APIs/services for company skill catalog operations, reset semantics, audit behavior, and portability provenance. - Updated adapter skill handling so runtime/catalog provenance remains explicit across local adapters. - Added board UI support for browsing and managing catalog-backed company skills. - Updated docs for the skills CLI/catalog flow and the company skills Paperclip skill reference. - Rebased the branch onto current `paperclipai/paperclip:master`; no `pnpm-lock.yaml`, `.github/workflows`, or migration files are included in the final PR diff. ## Verification - Passed: `pnpm run preflight:workspace-links && pnpm exec vitest run cli/src/__tests__/skills.test.ts packages/skills-catalog/src/catalog-builder.test.ts packages/skills-catalog/src/shipped-catalog.test.ts packages/shared/src/validators/company-skill.test.ts packages/adapter-utils/src/server-utils.test.ts packages/plugins/create-paperclip-plugin/src/entrypoints.test.ts server/src/__tests__/company-skills-catalog-service.test.ts server/src/__tests__/company-skills-routes.test.ts server/src/__tests__/company-portability.test.ts`. - Passed: `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts -t "default branch|origin/master|symbolic-ref"`. - Attempted: full `server/src/__tests__/workspace-runtime.test.ts`. Four provisioning tests failed while seeding an isolated worktree database from the local Paperclip instance because the local plugin schema dump contains a duplicate-column foreign key (`plugin_content_machine_18a7bc327b.content_case_signals`). The default-branch tests touched by the rebase conflict passed in the focused run above. - Checked final diff: no `pnpm-lock.yaml`, no `.github/workflows`, and no migration-file changes relative to `master`. ## Risks - Medium: this is a broad skills/catalog change touching CLI, server APIs, shared contracts, adapter skill sync, and UI. - Catalog validation and reset semantics need careful reviewer attention because they affect reusable company setup and portability. - No database migrations are included in this PR, so there is no migration ordering/idempotency risk in the final diff. - No lockfile is included by design; dependency resolution will be handled by the repository lockfile workflow. ## Model Used - OpenAI Codex coding agent based on GPT-5, running in Paperclip via the `codex_local` adapter with shell, git, GitHub CLI, and code-editing tool access. Exact hosted model build/context-window metadata is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run targeted tests locally and documented the local workspace-runtime seed failure above - [x] I have added or updated tests where applicable - [x] If this change affects the UI, screenshots were intentionally omitted per PAP-10124 instructions; UI behavior is covered by tests and reviewer inspection - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1018 lines
34 KiB
TypeScript
1018 lines
34 KiB
TypeScript
import { Command } from "commander";
|
|
import type {
|
|
Agent,
|
|
AgentSkillSnapshot,
|
|
CatalogSkill,
|
|
CompanySkill,
|
|
CompanySkillAuditResult,
|
|
CompanySkillDetail,
|
|
CompanySkillFileDetail,
|
|
CompanySkillImportResult,
|
|
CompanySkillInstallCatalogResult,
|
|
CompanySkillListItem,
|
|
CompanySkillProjectScanResult,
|
|
CompanySkillUpdateStatus,
|
|
} from "@paperclipai/shared";
|
|
import { readFile } from "node:fs/promises";
|
|
import { stdin as input, stdout as output } from "node:process";
|
|
import { createInterface } from "node:readline/promises";
|
|
import {
|
|
addCommonClientOptions,
|
|
formatInlineRecord,
|
|
handleCommandError,
|
|
printOutput,
|
|
resolveCommandContext,
|
|
type BaseClientOptions,
|
|
type ResolvedClientContext,
|
|
} from "./common.js";
|
|
|
|
interface SkillsOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
}
|
|
|
|
interface SkillFileOptions extends SkillsOptions {
|
|
path?: string;
|
|
}
|
|
|
|
interface SkillCreateOptions extends SkillsOptions {
|
|
name: string;
|
|
slug?: string;
|
|
description?: string;
|
|
bodyFile?: string;
|
|
}
|
|
|
|
interface SkillScanProjectsOptions extends SkillsOptions {
|
|
projectId?: string[];
|
|
workspaceId?: string[];
|
|
}
|
|
|
|
interface CatalogBrowseOptions extends BaseClientOptions {
|
|
kind?: string;
|
|
category?: string;
|
|
query?: string;
|
|
}
|
|
|
|
interface CatalogInstallOptions extends SkillsOptions {
|
|
as?: string;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface SkillUpdateOptions extends SkillsOptions {
|
|
all?: boolean;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface ConfirmedSkillOptions extends SkillsOptions {
|
|
yes?: boolean;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface AgentSkillSyncOptions extends SkillsOptions {
|
|
skill?: string[];
|
|
}
|
|
|
|
type CompanySkillReferenceTarget = Pick<CompanySkillListItem, "id" | "key" | "slug" | "name">;
|
|
|
|
export interface CompanySkillCheckRow {
|
|
skill: CompanySkillReferenceTarget;
|
|
status: CompanySkillUpdateStatus;
|
|
}
|
|
|
|
export interface CompanySkillUpdateRow {
|
|
skillRef: string;
|
|
action: "updated" | "skipped" | "failed";
|
|
skill?: CompanySkill;
|
|
status?: CompanySkillUpdateStatus;
|
|
reason?: string;
|
|
}
|
|
|
|
export function registerSkillsCommands(program: Command): void {
|
|
const skills = program.command("skills").description("Company and agent skill operations");
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("browse")
|
|
.description("Browse app-shipped catalog skills without installing them")
|
|
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
|
|
.option("--category <slug>", "Catalog category filter")
|
|
.option("--query <text>", "Search catalog text")
|
|
.action(async (opts: CatalogBrowseOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const rows = await listCatalogSkills(ctx, opts);
|
|
if (ctx.json) {
|
|
printOutput(rows, { json: true });
|
|
return;
|
|
}
|
|
printCatalogSkillRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("search")
|
|
.description("Search app-shipped catalog skills without installing them")
|
|
.argument("<query>", "Search text")
|
|
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
|
|
.option("--category <slug>", "Catalog category filter")
|
|
.action(async (query: string, opts: CatalogBrowseOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const rows = await listCatalogSkills(ctx, { ...opts, query });
|
|
if (ctx.json) {
|
|
printOutput(rows, { json: true });
|
|
return;
|
|
}
|
|
printCatalogSkillRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("inspect")
|
|
.description("Inspect an app-shipped catalog skill before installing it")
|
|
.argument("<catalogRef>", "Catalog skill ID, key, or unique slug")
|
|
.action(async (catalogRef: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const detail = await getCatalogSkill(ctx, catalogRef);
|
|
if (ctx.json) {
|
|
printOutput(detail, { json: true });
|
|
return;
|
|
}
|
|
printCatalogSkillDetail(detail);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("install")
|
|
.description("Install a catalog skill into the company skill library; does not attach it to agents")
|
|
.argument("<catalogRef>", "Catalog skill ID, key, or unique slug")
|
|
.option("--as <slug>", "Company skill slug override")
|
|
.option("--force", "Replace a same-key catalog-managed skill when the server allows it", false)
|
|
.action(async (catalogRef: string, opts: CatalogInstallOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const result = await ctx.api.post<CompanySkillInstallCatalogResult>(
|
|
`/api/companies/${ctx.companyId}/skills/install-catalog`,
|
|
{
|
|
catalogSkillId: catalogRef,
|
|
slug: opts.as,
|
|
force: opts.force || undefined,
|
|
},
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(result, { json: true });
|
|
return;
|
|
}
|
|
printCatalogInstallResult(result);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("list")
|
|
.description("List company skills")
|
|
.action(async (opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const rows = await listCompanySkills(ctx);
|
|
if (ctx.json) {
|
|
printOutput(rows, { json: true });
|
|
return;
|
|
}
|
|
printCompanySkillRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("show")
|
|
.description("Show company skill details")
|
|
.argument("<skillRef>", "Company skill ID, key, or unique slug")
|
|
.action(async (skillRef: string, opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const skill = await resolveCompanySkill(ctx, skillRef);
|
|
const detail = await ctx.api.get<CompanySkillDetail>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}`,
|
|
);
|
|
printOutput(detail, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("file")
|
|
.description("Print a company skill file")
|
|
.argument("<skillRef>", "Company skill ID, key, or unique slug")
|
|
.option("--path <path>", "Relative file path", "SKILL.md")
|
|
.action(async (skillRef: string, opts: SkillFileOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const skill = await resolveCompanySkill(ctx, skillRef);
|
|
const params = new URLSearchParams({ path: opts.path?.trim() || "SKILL.md" });
|
|
const file = await ctx.api.get<CompanySkillFileDetail>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}/files?${params.toString()}`,
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(file, { json: true });
|
|
return;
|
|
}
|
|
process.stdout.write(file?.content ?? "");
|
|
if (file?.content && !file.content.endsWith("\n")) {
|
|
process.stdout.write("\n");
|
|
}
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("import")
|
|
.description("Import company skills from a local path, GitHub, skills.sh, or URL source")
|
|
.argument("<source>", "Skill source")
|
|
.action(async (source: string, opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const result = await ctx.api.post<CompanySkillImportResult>(
|
|
`/api/companies/${ctx.companyId}/skills/import`,
|
|
{ source },
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(result, { json: true });
|
|
return;
|
|
}
|
|
console.log(
|
|
`Imported ${result?.imported.length ?? 0} skill(s); warnings=${result?.warnings.length ?? 0}`,
|
|
);
|
|
printCompanySkillRows(result?.imported ?? []);
|
|
for (const warning of result?.warnings ?? []) {
|
|
console.log(`warning=${warning}`);
|
|
}
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("create")
|
|
.description("Create a managed local company skill")
|
|
.requiredOption("--name <name>", "Skill name")
|
|
.option("--slug <slug>", "Skill slug")
|
|
.option("--description <text>", "Skill description")
|
|
.option("--body-file <path>", "Markdown body file; use - to read stdin")
|
|
.action(async (opts: SkillCreateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const markdown = opts.bodyFile ? await readBodyFile(opts.bodyFile) : undefined;
|
|
const created = await ctx.api.post<CompanySkill>(
|
|
`/api/companies/${ctx.companyId}/skills`,
|
|
{
|
|
name: opts.name,
|
|
slug: opts.slug,
|
|
description: opts.description,
|
|
markdown,
|
|
},
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(created, { json: true });
|
|
return;
|
|
}
|
|
console.log(`Created skill ${created?.name ?? opts.name} (${created?.key ?? created?.id ?? "unknown"})`);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("scan-projects")
|
|
.description("Scan project workspaces for skills")
|
|
.option("--project-id <id>", "Project ID to scan; may be repeated", collectOptionValue, [] as string[])
|
|
.option("--workspace-id <id>", "Workspace ID to scan; may be repeated", collectOptionValue, [] as string[])
|
|
.action(async (opts: SkillScanProjectsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const result = await ctx.api.post<CompanySkillProjectScanResult>(
|
|
`/api/companies/${ctx.companyId}/skills/scan-projects`,
|
|
{
|
|
projectIds: emptyToUndefined(opts.projectId),
|
|
workspaceIds: emptyToUndefined(opts.workspaceId),
|
|
},
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(result, { json: true });
|
|
return;
|
|
}
|
|
console.log(
|
|
`Scanned projects=${result?.scannedProjects ?? 0} workspaces=${result?.scannedWorkspaces ?? 0} discovered=${result?.discovered ?? 0} imported=${result?.imported.length ?? 0} updated=${result?.updated.length ?? 0} skipped=${result?.skipped.length ?? 0} conflicts=${result?.conflicts.length ?? 0} warnings=${result?.warnings.length ?? 0}`,
|
|
);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("check")
|
|
.description("Check company skill update status")
|
|
.argument("[skillRef]", "Company skill ID, key, or unique slug")
|
|
.action(async (skillRef: string | undefined, opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const rows = await checkCompanySkills(ctx, skillRef);
|
|
if (ctx.json) {
|
|
printOutput(rows, { json: true });
|
|
return;
|
|
}
|
|
printCompanySkillCheckRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("update")
|
|
.description("Install company skill updates")
|
|
.argument("[skillRef]", "Company skill ID, key, or unique slug")
|
|
.option("--all", "Check all skills and install available updates", false)
|
|
.option("--force", "Discard local-modification or soft-audit holds; hard-stop audit findings still fail", false)
|
|
.action(async (skillRef: string | undefined, opts: SkillUpdateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
if (opts.all && skillRef?.trim()) {
|
|
throw new Error("Use either a skill reference or --all, not both.");
|
|
}
|
|
const rows = opts.all
|
|
? await updateAllCompanySkills(ctx, opts)
|
|
: [await updateOneCompanySkill(ctx, requireSkillRef(skillRef), opts)];
|
|
if (ctx.json) {
|
|
printOutput(rows.length === 1 && !opts.all ? rows[0] : rows, { json: true });
|
|
return;
|
|
}
|
|
printCompanySkillUpdateRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("audit")
|
|
.description("Audit installed company skill bytes without executing them")
|
|
.argument("[skillRef]", "Company skill ID, key, or unique slug")
|
|
.action(async (skillRef: string | undefined, opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const rows = await auditCompanySkills(ctx, skillRef);
|
|
if (ctx.json) {
|
|
printOutput(rows.length === 1 && skillRef ? rows[0]?.audit : rows, { json: true });
|
|
return;
|
|
}
|
|
printCompanySkillAuditRows(rows);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("reset")
|
|
.description("Reset a catalog-managed company skill to its pinned installed origin")
|
|
.argument("<skillRef>", "Company skill ID, key, or unique slug")
|
|
.option("--yes", "Confirm reset without prompting", false)
|
|
.option("--force", "Discard local modifications or accept soft audit warnings; hard-stop audit findings still fail", false)
|
|
.action(async (skillRef: string, opts: ConfirmedSkillOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const skill = await resolveCompanySkill(ctx, skillRef);
|
|
await confirmDangerousAction(opts.yes, `Reset catalog skill "${skill.name}" (${skill.key}) to its pinned origin?`);
|
|
const reset = await ctx.api.post<CompanySkill>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}/reset`,
|
|
{ force: opts.force || undefined },
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(reset, { json: true });
|
|
return;
|
|
}
|
|
console.log(`Reset skill ${reset?.name ?? skill.name} (${reset?.key ?? skill.key}) to pinned origin.`);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
skills
|
|
.command("remove")
|
|
.description("Remove a company skill")
|
|
.argument("<skillRef>", "Company skill ID, key, or unique slug")
|
|
.option("--yes", "Confirm removal without prompting", false)
|
|
.action(async (skillRef: string, opts: ConfirmedSkillOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const skill = await resolveCompanySkill(ctx, skillRef);
|
|
await confirmDangerousAction(opts.yes, `Remove company skill "${skill.name}" (${skill.key})?`);
|
|
const removed = await ctx.api.delete<CompanySkill>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}`,
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(removed, { json: true });
|
|
return;
|
|
}
|
|
console.log(`Removed skill ${removed?.name ?? skill.name} (${removed?.key ?? skill.key})`);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
registerAgentSkillCommands(skills);
|
|
}
|
|
|
|
function registerAgentSkillCommands(skills: Command): void {
|
|
const agent = skills.command("agent").description("Agent desired-skill and runtime sync operations");
|
|
|
|
addCommonClientOptions(
|
|
agent
|
|
.command("list")
|
|
.description("List an agent runtime skill snapshot")
|
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
|
.action(async (agentRef: string, opts: SkillsOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const agentRow = await resolveAgent(ctx, agentRef);
|
|
const snapshot = await ctx.api.get<AgentSkillSnapshot>(
|
|
`/api/agents/${encodeURIComponent(agentRow.id)}/skills`,
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(snapshot, { json: true });
|
|
return;
|
|
}
|
|
printAgentSkillSnapshot(snapshot, agentRow);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
agent
|
|
.command("sync")
|
|
.description("Replace an agent's non-required desired company skills and sync runtime state")
|
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
|
.option("--skill <skillRef>", "Desired company skill ID, key, or slug; may be repeated", collectOptionValue, [] as string[])
|
|
.action(async (agentRef: string, opts: AgentSkillSyncOptions) => {
|
|
try {
|
|
const desiredSkills = opts.skill ?? [];
|
|
if (desiredSkills.length === 0) {
|
|
throw new Error("At least one --skill value is required for skills agent sync.");
|
|
}
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const agentRow = await resolveAgent(ctx, agentRef);
|
|
const snapshot = await ctx.api.post<AgentSkillSnapshot>(
|
|
`/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`,
|
|
{ desiredSkills },
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(snapshot, { json: true });
|
|
return;
|
|
}
|
|
console.log(
|
|
`Desired company skills replaced for ${agentRow.name} (${agentRow.id}); runtime sync returned ${snapshot?.entries.length ?? 0} entrie(s).`,
|
|
);
|
|
printAgentSkillSnapshot(snapshot, agentRow);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
agent
|
|
.command("clear")
|
|
.description("Clear an agent's non-required desired company skills and sync runtime state")
|
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
|
.option("--yes", "Confirm clear without prompting", false)
|
|
.action(async (agentRef: string, opts: ConfirmedSkillOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const agentRow = await resolveAgent(ctx, agentRef);
|
|
await confirmDangerousAction(
|
|
opts.yes,
|
|
`Clear non-required desired company skills for "${agentRow.name}" (${agentRow.id})?`,
|
|
);
|
|
const snapshot = await ctx.api.post<AgentSkillSnapshot>(
|
|
`/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`,
|
|
{ desiredSkills: [] },
|
|
);
|
|
if (ctx.json) {
|
|
printOutput(snapshot, { json: true });
|
|
return;
|
|
}
|
|
console.log(
|
|
`Desired company skills cleared for ${agentRow.name} (${agentRow.id}); required Paperclip skills remain server-enforced.`,
|
|
);
|
|
printAgentSkillSnapshot(snapshot, agentRow);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: true },
|
|
);
|
|
}
|
|
|
|
async function listCompanySkills(ctx: ResolvedClientContext): Promise<CompanySkillListItem[]> {
|
|
return (await ctx.api.get<CompanySkillListItem[]>(`/api/companies/${ctx.companyId}/skills`)) ?? [];
|
|
}
|
|
|
|
async function listCatalogSkills(
|
|
ctx: ResolvedClientContext,
|
|
opts: CatalogBrowseOptions,
|
|
): Promise<CatalogSkill[]> {
|
|
const params = new URLSearchParams();
|
|
appendQueryParam(params, "kind", opts.kind);
|
|
appendQueryParam(params, "category", opts.category);
|
|
appendQueryParam(params, "q", opts.query);
|
|
const query = params.toString();
|
|
return (await ctx.api.get<CatalogSkill[]>(`/api/skills/catalog${query ? `?${query}` : ""}`)) ?? [];
|
|
}
|
|
|
|
async function getCatalogSkill(ctx: ResolvedClientContext, catalogRef: string): Promise<CatalogSkill> {
|
|
const ref = catalogRef.trim();
|
|
if (!ref) {
|
|
throw new Error("Catalog skill reference is required.");
|
|
}
|
|
const detail = await ctx.api.get<CatalogSkill>(`/api/skills/catalog/ref?ref=${encodeURIComponent(ref)}`);
|
|
if (!detail) {
|
|
throw new Error(`Catalog skill not found: ${catalogRef}`);
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
export function resolveCompanySkillReference(
|
|
skills: CompanySkillReferenceTarget[],
|
|
reference: string,
|
|
): CompanySkillReferenceTarget {
|
|
const trimmed = reference.trim();
|
|
if (!trimmed) {
|
|
throw new Error("Skill reference is required.");
|
|
}
|
|
|
|
const byId = skills.find((skill) => skill.id === trimmed);
|
|
if (byId) return byId;
|
|
|
|
const byKey = skills.find((skill) => skill.key === trimmed);
|
|
if (byKey) return byKey;
|
|
|
|
const normalizedSlug = normalizeSkillSlug(trimmed);
|
|
const bySlug = skills.filter((skill) => skill.slug === normalizedSlug);
|
|
if (bySlug.length === 1 && bySlug[0]) return bySlug[0];
|
|
if (bySlug.length > 1) {
|
|
throw new Error(`Ambiguous skill slug "${trimmed}". Use a skill ID or key instead.`);
|
|
}
|
|
|
|
throw new Error(`Skill not found: ${reference}`);
|
|
}
|
|
|
|
async function resolveCompanySkill(
|
|
ctx: ResolvedClientContext,
|
|
reference: string,
|
|
): Promise<CompanySkillReferenceTarget> {
|
|
return resolveCompanySkillReference(await listCompanySkills(ctx), reference);
|
|
}
|
|
|
|
async function checkCompanySkills(
|
|
ctx: ResolvedClientContext,
|
|
skillRef: string | undefined,
|
|
): Promise<CompanySkillCheckRow[]> {
|
|
const skills = await listCompanySkills(ctx);
|
|
const selected = skillRef ? [resolveCompanySkillReference(skills, skillRef)] : skills;
|
|
const rows: CompanySkillCheckRow[] = [];
|
|
for (const skill of selected) {
|
|
const status = await ctx.api.get<CompanySkillUpdateStatus>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}/update-status`,
|
|
);
|
|
if (!status) {
|
|
throw new Error(`No update status returned for skill ${skill.key}.`);
|
|
}
|
|
rows.push({ skill: toSkillReferenceTarget(skill), status });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function updateOneCompanySkill(
|
|
ctx: ResolvedClientContext,
|
|
skillRef: string,
|
|
opts: SkillUpdateOptions = {},
|
|
): Promise<CompanySkillUpdateRow> {
|
|
const skill = await resolveCompanySkill(ctx, skillRef);
|
|
const updated = await ctx.api.post<CompanySkill>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}/install-update`,
|
|
{ force: opts.force || undefined },
|
|
);
|
|
return {
|
|
skillRef,
|
|
action: "updated",
|
|
skill: updated ?? undefined,
|
|
};
|
|
}
|
|
|
|
async function updateAllCompanySkills(ctx: ResolvedClientContext, opts: SkillUpdateOptions = {}): Promise<CompanySkillUpdateRow[]> {
|
|
const checks = await checkCompanySkills(ctx, undefined);
|
|
const rows: CompanySkillUpdateRow[] = [];
|
|
for (const row of checks) {
|
|
if (!row.status.supported) {
|
|
rows.push({
|
|
skillRef: row.skill.key,
|
|
action: "skipped",
|
|
status: row.status,
|
|
reason: row.status.reason ?? "Update checks are not supported for this skill.",
|
|
});
|
|
continue;
|
|
}
|
|
if (!row.status.hasUpdate) {
|
|
rows.push({
|
|
skillRef: row.skill.key,
|
|
action: "skipped",
|
|
status: row.status,
|
|
reason: "Already current.",
|
|
});
|
|
continue;
|
|
}
|
|
try {
|
|
const updated = await ctx.api.post<CompanySkill>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(row.skill.id)}/install-update`,
|
|
{ force: opts.force || undefined },
|
|
);
|
|
rows.push({
|
|
skillRef: row.skill.key,
|
|
action: "updated",
|
|
status: row.status,
|
|
skill: updated ?? undefined,
|
|
});
|
|
} catch (err) {
|
|
rows.push({
|
|
skillRef: row.skill.key,
|
|
action: "failed",
|
|
status: row.status,
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function auditCompanySkills(
|
|
ctx: ResolvedClientContext,
|
|
skillRef: string | undefined,
|
|
): Promise<Array<{ skill: CompanySkillReferenceTarget; audit: CompanySkillAuditResult }>> {
|
|
const skills = await listCompanySkills(ctx);
|
|
const selected = skillRef ? [resolveCompanySkillReference(skills, skillRef)] : skills;
|
|
const rows: Array<{ skill: CompanySkillReferenceTarget; audit: CompanySkillAuditResult }> = [];
|
|
for (const skill of selected) {
|
|
const audit = await ctx.api.post<CompanySkillAuditResult>(
|
|
`/api/companies/${ctx.companyId}/skills/${encodeURIComponent(skill.id)}/audit`,
|
|
{},
|
|
);
|
|
if (!audit) {
|
|
throw new Error(`No audit result returned for skill ${skill.key}.`);
|
|
}
|
|
rows.push({ skill: toSkillReferenceTarget(skill), audit });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function resolveAgent(ctx: ResolvedClientContext, agentRef: string): Promise<Agent> {
|
|
const params = new URLSearchParams({ companyId: ctx.companyId ?? "" });
|
|
const agent = await ctx.api.get<Agent>(`/api/agents/${encodeURIComponent(agentRef)}?${params.toString()}`);
|
|
if (!agent) {
|
|
throw new Error(`Agent not found: ${agentRef}`);
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
function printCompanySkillRows(rows: Array<CompanySkillListItem | CompanySkill>): void {
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
for (const row of rows) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
id: row.id,
|
|
key: row.key,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
source: "sourceBadge" in row ? row.sourceBadge : row.sourceType,
|
|
trust: row.trustLevel,
|
|
compatibility: row.compatibility,
|
|
attachedAgents: "attachedAgentCount" in row ? row.attachedAgentCount : undefined,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function printCatalogSkillRows(rows: CatalogSkill[]): void {
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
printTable(rows.map((row) => ({
|
|
id: row.id,
|
|
key: row.key,
|
|
kind: row.kind,
|
|
category: row.category,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
trust: row.trustLevel,
|
|
roles: row.recommendedForRoles.join(",") || "-",
|
|
})));
|
|
}
|
|
|
|
function printCatalogSkillDetail(skill: CatalogSkill): void {
|
|
console.log(
|
|
formatInlineRecord({
|
|
id: skill.id,
|
|
key: skill.key,
|
|
kind: skill.kind,
|
|
category: skill.category,
|
|
slug: skill.slug,
|
|
name: skill.name,
|
|
trust: skill.trustLevel,
|
|
compatibility: skill.compatibility,
|
|
contentHash: skill.contentHash,
|
|
}),
|
|
);
|
|
console.log(`description=${skill.description || "-"}`);
|
|
console.log(`recommendedForRoles=${skill.recommendedForRoles.join(",") || "-"}`);
|
|
console.log(`tags=${skill.tags.join(",") || "-"}`);
|
|
console.log("files:");
|
|
printTable(skill.files.map((file) => ({
|
|
path: file.path,
|
|
kind: file.kind,
|
|
sizeBytes: file.sizeBytes,
|
|
sha256: file.sha256,
|
|
})));
|
|
}
|
|
|
|
function printCatalogInstallResult(result: CompanySkillInstallCatalogResult | null): void {
|
|
if (!result) {
|
|
console.log("Catalog install returned no result.");
|
|
return;
|
|
}
|
|
console.log(
|
|
`Catalog skill ${result.action}: ${result.skill.name} (${result.skill.key}) in company skill library.`,
|
|
);
|
|
console.log(
|
|
"This does not attach the skill to an agent. Use `paperclipai skills agent sync <agent> --skill <skill>` when you want an agent to use it.",
|
|
);
|
|
for (const warning of result.warnings) {
|
|
console.log(`warning=${warning}`);
|
|
}
|
|
}
|
|
|
|
function printCompanySkillCheckRows(rows: CompanySkillCheckRow[]): void {
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
for (const row of rows) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
id: row.skill.id,
|
|
key: row.skill.key,
|
|
slug: row.skill.slug,
|
|
name: row.skill.name,
|
|
supported: row.status.supported,
|
|
hasUpdate: row.status.hasUpdate,
|
|
currentRef: row.status.currentRef,
|
|
latestRef: row.status.latestRef,
|
|
installedHash: row.status.installedHash,
|
|
originHash: row.status.originHash,
|
|
hold: row.status.updateHoldReason,
|
|
audit: row.status.auditVerdict,
|
|
reason: row.status.reason,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function printCompanySkillAuditRows(rows: Array<{ skill: CompanySkillReferenceTarget; audit: CompanySkillAuditResult }>): void {
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
for (const row of rows) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
id: row.skill.id,
|
|
key: row.skill.key,
|
|
slug: row.skill.slug,
|
|
verdict: row.audit.verdict,
|
|
installedHash: row.audit.installedHash,
|
|
originHash: row.audit.originHash,
|
|
codes: row.audit.codes.join(",") || null,
|
|
}),
|
|
);
|
|
for (const finding of row.audit.findings) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
severity: finding.severity,
|
|
code: finding.code,
|
|
path: finding.path,
|
|
message: finding.message,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function printCompanySkillUpdateRows(rows: CompanySkillUpdateRow[]): void {
|
|
for (const row of rows) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
action: row.action,
|
|
skillRef: row.skillRef,
|
|
key: row.skill?.key,
|
|
slug: row.skill?.slug,
|
|
hasUpdate: row.status?.hasUpdate,
|
|
reason: row.reason,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function printAgentSkillSnapshot(snapshot: AgentSkillSnapshot | null, agent: Agent): void {
|
|
if (!snapshot) {
|
|
console.log(`Agent ${agent.name} (${agent.id}) returned no skill snapshot.`);
|
|
return;
|
|
}
|
|
console.log(
|
|
`Agent ${agent.name} (${agent.id}) adapter=${snapshot.adapterType} supported=${snapshot.supported} mode=${snapshot.mode} desiredCompanySkills=${snapshot.desiredSkills.length}`,
|
|
);
|
|
if (snapshot.warnings.length > 0) {
|
|
for (const warning of snapshot.warnings) {
|
|
console.log(`warning=${warning}`);
|
|
}
|
|
}
|
|
if (snapshot.entries.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
for (const entry of snapshot.entries) {
|
|
console.log(
|
|
formatInlineRecord({
|
|
key: entry.key,
|
|
runtimeName: entry.runtimeName,
|
|
desired: entry.desired,
|
|
managed: entry.managed,
|
|
required: entry.required ?? false,
|
|
state: entry.state,
|
|
origin: entry.origin,
|
|
detail: entry.detail,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function toSkillReferenceTarget(skill: CompanySkillReferenceTarget): CompanySkillReferenceTarget {
|
|
return {
|
|
id: skill.id,
|
|
key: skill.key,
|
|
slug: skill.slug,
|
|
name: skill.name,
|
|
};
|
|
}
|
|
|
|
function normalizeSkillSlug(value: string): string {
|
|
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
function requireSkillRef(skillRef: string | undefined): string {
|
|
if (!skillRef?.trim()) {
|
|
throw new Error("Skill reference is required unless --all is used.");
|
|
}
|
|
return skillRef;
|
|
}
|
|
|
|
function collectOptionValue(value: string, previous: string[]): string[] {
|
|
return [...previous, value];
|
|
}
|
|
|
|
function emptyToUndefined(values: string[] | undefined): string[] | undefined {
|
|
return values && values.length > 0 ? values : undefined;
|
|
}
|
|
|
|
function appendQueryParam(params: URLSearchParams, key: string, value: string | undefined): void {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) {
|
|
params.set(key, trimmed);
|
|
}
|
|
}
|
|
|
|
function printTable(rows: Array<Record<string, unknown>>): void {
|
|
if (rows.length === 0) {
|
|
printOutput([], { json: false });
|
|
return;
|
|
}
|
|
const columns = Object.keys(rows[0] ?? {});
|
|
const widths = new Map(columns.map((column) => [column, column.length]));
|
|
for (const row of rows) {
|
|
for (const column of columns) {
|
|
widths.set(column, Math.max(widths.get(column) ?? 0, renderTableValue(row[column]).length));
|
|
}
|
|
}
|
|
console.log(columns.map((column) => column.padEnd(widths.get(column) ?? column.length)).join(" "));
|
|
console.log(columns.map((column) => "-".repeat(widths.get(column) ?? column.length)).join(" "));
|
|
for (const row of rows) {
|
|
console.log(
|
|
columns
|
|
.map((column) => renderTableValue(row[column]).padEnd(widths.get(column) ?? column.length))
|
|
.join(" "),
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderTableValue(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
async function readBodyFile(filePath: string): Promise<string> {
|
|
if (filePath === "-") {
|
|
return readStdin();
|
|
}
|
|
return readFile(filePath, "utf8");
|
|
}
|
|
|
|
async function readStdin(): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
async function confirmDangerousAction(yes: boolean | undefined, message: string): Promise<void> {
|
|
if (yes) return;
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
throw new Error("This command requires --yes when not running in an interactive terminal.");
|
|
}
|
|
const rl = createInterface({ input, output });
|
|
try {
|
|
const answer = (await rl.question(`${message} Type yes to continue: `)).trim().toLowerCase();
|
|
if (answer !== "yes") {
|
|
throw new Error("Aborted.");
|
|
}
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|