diff --git a/docs/user-guide/asset-registry-commands.md b/docs/user-guide/asset-registry-commands.md index c333a551..7d5e289a 100644 --- a/docs/user-guide/asset-registry-commands.md +++ b/docs/user-guide/asset-registry-commands.md @@ -149,6 +149,71 @@ Options: - `--assetType ` (required) – The asset type identifier - `--json` – Write the examples to a JSON file in the working directory +## Skills + +The asset registry also publishes agent skills (authored guidance for the platform and for specific asset types). Each skill exposes a `SKILL.md` and optional reference files. + +### List Skills + +List all skills available on the platform. + +``` +content-cli asset-registry skills list +``` + +Example output: + +``` +content-cli-setup (platform/content-cli-setup) - Install content-cli and create a profile against a Celonis team. +asset-studio-board-v2 (asset/BOARD_V2/asset-studio-board-v2) - Authoring one Celonis Studio view asset of type BOARD_V2. +``` + +Each line is ` ()` followed by ` - ` when the skill provides one. The `` value is what you pass to `skills get --path`. + +Use `--json` to write the full response to a JSON file in the working directory: + +``` +content-cli asset-registry skills list --json +``` + +### Download a Skill File + +Download a skill's `SKILL.md` (or a specific reference file) to the local filesystem. The Studio MCP server remains the recommended source for live agent use; this command is a fetch/inspect utility for environments without the MCP server, for offline review, or for vendoring a copy into a repo. + +Download the default `SKILL.md` for a platform skill: + +``` +content-cli asset-registry skills get --path platform/content-cli-setup +``` + +Download a `SKILL.md` for an asset skill: + +``` +content-cli asset-registry skills get --path asset/BOARD_V2/asset-studio-board-v2 +``` + +Download a specific reference file and write into a target directory: + +``` +content-cli asset-registry skills get \ + --path asset/BOARD_V2/asset-studio-board-v2 \ + --file refs/example.md \ + --output ./skills +``` + +Options: + +- `--path ` (required) – Skill path from `asset-registry skills list` (e.g. `platform/` or `asset//`). +- `--file ` – Relative path of a reference file within the skill. Defaults to `SKILL.md` when omitted. +- `--output ` – Destination directory. Defaults to the current working directory. Created automatically if it does not exist. + +Behavior: + +- The local filename is the basename of `--file` (or `SKILL.md` when `--file` is omitted). Subdirectories in `--file` are not preserved on the local side. +- Re-running the command overwrites the existing local file without prompting. +- On success the command logs a single confirmation line with the absolute path of the written file. +- A missing skill or file returns a clear error such as `Problem getting SKILL.md for 'platform/missing': ...`. + ## Troubleshooting If the asset registry is disabled on your team, commands fail with: diff --git a/src/commands/asset-registry/asset-registry-api.ts b/src/commands/asset-registry/asset-registry-api.ts index abb23aa5..1a505240 100644 --- a/src/commands/asset-registry/asset-registry-api.ts +++ b/src/commands/asset-registry/asset-registry-api.ts @@ -6,8 +6,11 @@ import { AssetRegistryMetadata, } from "./asset-registry.interfaces"; import { handleAssetRegistryApiError } from "./asset-registry-error"; +import { trimSlashes } from "../../core/utils/path"; export class AssetRegistryApi { + private static readonly BASE_URL = "/pacman/api/core/asset-registry"; + private httpClient: () => HttpClient; constructor(context: Context) { @@ -16,37 +19,63 @@ export class AssetRegistryApi { public async listTypes(): Promise { return this.httpClient() - .get("/pacman/api/core/asset-registry/types") + .get(AssetRegistryApi.endpointUrl("types")) .catch((e) => handleAssetRegistryApiError("listing asset registry types", e)); } public async listSkills(): Promise { return this.httpClient() - .get("/pacman/api/core/asset-registry/skills") + .get(AssetRegistryApi.endpointUrl("skills")) .catch((e) => handleAssetRegistryApiError("listing asset registry skills", e)); } public async getType(assetType: string): Promise { return this.httpClient() - .get(`/pacman/api/core/asset-registry/types/${encodeURIComponent(assetType)}`) + .get(AssetRegistryApi.endpointUrl("types", encodeURIComponent(assetType))) .catch((e) => handleAssetRegistryApiError(`getting asset type '${assetType}'`, e)); } public async getSchema(assetType: string): Promise { return this.httpClient() - .get(`/pacman/api/core/asset-registry/schemas/${encodeURIComponent(assetType)}`) + .get(AssetRegistryApi.endpointUrl("schemas", encodeURIComponent(assetType))) .catch((e) => handleAssetRegistryApiError(`getting schema for asset type '${assetType}'`, e)); } public async getExamples(assetType: string): Promise { return this.httpClient() - .get(`/pacman/api/core/asset-registry/examples/${encodeURIComponent(assetType)}`) + .get(AssetRegistryApi.endpointUrl("examples", encodeURIComponent(assetType))) .catch((e) => handleAssetRegistryApiError(`getting examples for asset type '${assetType}'`, e)); } public async validate(assetType: string, body: any): Promise { return this.httpClient() - .post(`/pacman/api/core/asset-registry/validate/${encodeURIComponent(assetType)}`, body) + .post(AssetRegistryApi.endpointUrl("validate", encodeURIComponent(assetType)), body) .catch((e) => handleAssetRegistryApiError(`validating asset type '${assetType}'`, e)); } + + public async getSkillFile(skillPath: string, filePath?: string): Promise { + const operation = filePath + ? `getting skill file '${filePath}' for '${skillPath}'` + : `getting SKILL.md for '${skillPath}'`; + const url = this.buildSkillFileUrl(skillPath, filePath); + return this.httpClient() + .getFile(url) + .catch((e) => handleAssetRegistryApiError(operation, e)); + } + + private buildSkillFileUrl(skillPath: string, filePath?: string): string { + const segments = ["skills", encodePathSegments(skillPath)]; + if (filePath) { + segments.push(encodePathSegments(filePath)); + } + return AssetRegistryApi.endpointUrl(...segments); + } + + private static endpointUrl(...segments: string[]): string { + return `${AssetRegistryApi.BASE_URL}/${segments.join("/")}`; + } +} + +function encodePathSegments(value: string): string { + return trimSlashes(value).split("/").map(encodeURIComponent).join("/"); } diff --git a/src/commands/asset-registry/asset-registry.interfaces.ts b/src/commands/asset-registry/asset-registry.interfaces.ts index aa0ba8b7..baf0c547 100644 --- a/src/commands/asset-registry/asset-registry.interfaces.ts +++ b/src/commands/asset-registry/asset-registry.interfaces.ts @@ -57,3 +57,9 @@ export interface AgentSkill { export interface AgentSkillsResponse { skills: AgentSkill[]; } + +export interface GetSkillFileOptions { + path: string; + file?: string; + output?: string; +} diff --git a/src/commands/asset-registry/asset-registry.service.ts b/src/commands/asset-registry/asset-registry.service.ts index 965879a1..2890e0d2 100644 --- a/src/commands/asset-registry/asset-registry.service.ts +++ b/src/commands/asset-registry/asset-registry.service.ts @@ -1,9 +1,11 @@ import { AssetRegistryApi } from "./asset-registry-api"; -import { AgentSkill, AssetRegistryDescriptor, ValidateOptions } from "./asset-registry.interfaces"; +import { AgentSkill, AssetRegistryDescriptor, GetSkillFileOptions, ValidateOptions } from "./asset-registry.interfaces"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { FatalError, logger } from "../../core/utils/logger"; +import { trimSlashes } from "../../core/utils/path"; import { v4 as uuidv4 } from "uuid"; +import * as path from "node:path"; export class AssetRegistryService { private api: AssetRegistryApi; @@ -31,6 +33,28 @@ export class AssetRegistryService { } } + public async getSkillFile(opts: GetSkillFileOptions): Promise { + const filename = this.resolveLocalFilename(opts.file); + const targetDir = opts.output ?? "."; + + const buffer = await this.api.getSkillFile(opts.path, opts.file); + const absolutePath = fileService.writeBufferToPath(targetDir, filename, buffer); + + logger.info(FileService.fileDownloadedMessage + absolutePath); + } + + private resolveLocalFilename(file?: string): string { + if (!file) { + return "SKILL.md"; + } + const trimmed = trimSlashes(file); + const base = trimmed ? path.basename(trimmed) : ""; + if (!base) { + throw new FatalError(`--file must point to a file, got '${file}'.`); + } + return base; + } + public async listSkills(jsonResponse: boolean): Promise { const response = await this.api.listSkills(); diff --git a/src/commands/asset-registry/module.ts b/src/commands/asset-registry/module.ts index 7ceed64c..3de46aa2 100644 --- a/src/commands/asset-registry/module.ts +++ b/src/commands/asset-registry/module.ts @@ -49,6 +49,13 @@ class Module extends IModule { .description("List all available agent skills (name, description, path)") .option("--json", "Return the response as a JSON file") .action(this.listSkills); + + skillsCommand.command("get") + .description("Download a skill file (defaults to SKILL.md)") + .requiredOption("--path ", "Skill path from 'skills list' (e.g. platform/ or asset//)") + .option("--file ", "Relative path of a reference file within the skill (defaults to SKILL.md)") + .option("--output ", "Destination directory (defaults to current working directory)") + .action(this.getSkillFile); } private async listTypes(context: Context, command: Command, options: OptionValues): Promise { @@ -81,6 +88,14 @@ class Module extends IModule { private async listSkills(context: Context, command: Command, options: OptionValues): Promise { await new AssetRegistryService(context).listSkills(!!options.json); } + + private async getSkillFile(context: Context, command: Command, options: OptionValues): Promise { + await new AssetRegistryService(context).getSkillFile({ + path: options.path, + file: options.file, + output: options.output, + }); + } } export = Module; diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 942856d9..5b305505 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -22,16 +22,24 @@ export class FileService { }); } + public writeBufferToPath(targetDir: string, filename: string, data: Buffer): string { + const resolvedDir = path.resolve(process.cwd(), targetDir); + const absolutePath = path.join(resolvedDir, filename); + this.mkdirRecursive(resolvedDir); + this.writeBufferToFileWithGivenName(data, absolutePath); + return absolutePath; + } + public extractZipBufferToDirectory(data: Buffer, targetDir: string): void { const targetPath = path.resolve(process.cwd(), targetDir); - fs.mkdirSync(targetPath, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(targetPath); new AdmZip(data).extractAllTo(targetPath, true, true); this.restrictFilePermissions(targetPath); } public extractZipBufferToTempDirectory(data: Buffer): string { const tempDir = path.join(os.tmpdir(), `content-cli-${uuidv4()}`); - fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(tempDir); new AdmZip(data).extractAllTo(tempDir, true, true); this.restrictFilePermissions(tempDir); return tempDir; @@ -57,7 +65,7 @@ export class FileService { } public extractExportedZipWithNestedZipsToDir(zipFile: AdmZip, targetDir: string): string { - fs.mkdirSync(targetDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(targetDir); zipFile.extractAllTo(targetDir, true, true); const files = fs.readdirSync(targetDir); @@ -67,7 +75,7 @@ export class FileService { const nestedZip = new AdmZip(innerZipPath); const nestedDir = innerZipPath.replace(/\.zip$/, ""); - fs.mkdirSync(nestedDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(nestedDir); nestedZip.extractAllTo(nestedDir, true, true); fs.rmSync(innerZipPath); // Optionally remove the inner zip } @@ -104,7 +112,7 @@ export class FileService { }); const tempDir = path.join(os.tmpdir(), "content-cli-exports"); - fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(tempDir); const zipFilePath = path.join(tempDir, `export_${uuidv4()}.zip`); finalZip.writeZip(zipFilePath, () => fs.chmodSync(zipFilePath, FileConstants.DEFAULT_FILE_PERMISSIONS)); @@ -120,7 +128,7 @@ export class FileService { zip.addLocalFolder(sourceDir); const tempDir = path.join(os.tmpdir(), "content-cli-imports"); - fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + this.mkdirRecursive(tempDir); const zipFilePath = path.join(tempDir, `single_package_${uuidv4()}.zip`); zip.writeZip(zipFilePath, () => fs.chmodSync(zipFilePath, FileConstants.DEFAULT_FILE_PERMISSIONS)); @@ -132,6 +140,10 @@ export class FileService { return data; } + private mkdirRecursive(dir: string): void { + fs.mkdirSync(dir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + } + private restrictFilePermissions(targetDir: string): void { const files = fs.readdirSync(targetDir); for (const file of files) { diff --git a/src/core/utils/path.ts b/src/core/utils/path.ts new file mode 100644 index 00000000..ad06bde4 --- /dev/null +++ b/src/core/utils/path.ts @@ -0,0 +1,3 @@ +export function trimSlashes(value: string): string { + return value.replace(/^\/+/, "").replace(/\/+$/, ""); +} diff --git a/tests/commands/asset-registry/asset-registry-skills-get.spec.ts b/tests/commands/asset-registry/asset-registry-skills-get.spec.ts new file mode 100644 index 00000000..2b2d202d --- /dev/null +++ b/tests/commands/asset-registry/asset-registry-skills-get.spec.ts @@ -0,0 +1,164 @@ +import * as fs from "node:fs"; +import * as path from "path"; +import { mockAxiosGet, mockAxiosGetError } from "../../utls/http-requests-mock"; +import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; +import { FatalError } from "../../../src/core/utils/logger"; +import { FileService } from "../../../src/core/utils/file-service"; +import { uniqueDirName } from "../../utls/fs-utils"; + +const SKILLS_BASE_URL = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/skills"; + +describe("Asset registry skills get", () => { + const skillContent = Buffer.from("# Hello SKILL\n\nLine 2.\n", "utf-8"); + + function absoluteOutputDir(outputDir: string): string { + return path.resolve(process.cwd(), outputDir); + } + + it("Should download SKILL.md by default for a platform skill", async () => { + mockAxiosGet(`${SKILLS_BASE_URL}/platform/foo`, skillContent); + const output = uniqueDirName(); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + output, + }); + + const written = path.join(absoluteOutputDir(output), "SKILL.md"); + expect(fs.existsSync(written)).toBe(true); + expect(fs.readFileSync(written).equals(skillContent)).toBe(true); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain( + FileService.fileDownloadedMessage + written + ); + }); + + it("Should download SKILL.md by default for an asset skill (multi-segment path)", async () => { + mockAxiosGet(`${SKILLS_BASE_URL}/asset/BOARD_V2/board-authoring`, skillContent); + const output = uniqueDirName(); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "asset/BOARD_V2/board-authoring", + output, + }); + + const written = path.join(absoluteOutputDir(output), "SKILL.md"); + expect(fs.existsSync(written)).toBe(true); + expect(fs.readFileSync(written).equals(skillContent)).toBe(true); + }); + + it("Should write a reference file using only its basename (strips --file subdirs)", async () => { + const refContent = Buffer.from("ref content", "utf-8"); + mockAxiosGet(`${SKILLS_BASE_URL}/platform/foo/refs/style.md`, refContent); + const output = uniqueDirName(); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + file: "refs/style.md", + output, + }); + + const written = path.join(absoluteOutputDir(output), "style.md"); + expect(fs.existsSync(written)).toBe(true); + expect(fs.readFileSync(written).equals(refContent)).toBe(true); + + const subdirWritten = path.join(absoluteOutputDir(output), "refs", "style.md"); + expect(fs.existsSync(subdirWritten)).toBe(false); + }); + + it("Should create the --output directory if it does not exist", async () => { + mockAxiosGet(`${SKILLS_BASE_URL}/platform/foo`, skillContent); + const output = path.join(uniqueDirName(), "nested", "deep"); + expect(fs.existsSync(absoluteOutputDir(output))).toBe(false); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + output, + }); + + expect(fs.existsSync(absoluteOutputDir(output))).toBe(true); + const written = path.join(absoluteOutputDir(output), "SKILL.md"); + expect(fs.existsSync(written)).toBe(true); + expect(fs.readFileSync(written).equals(skillContent)).toBe(true); + }); + + it("Should overwrite an existing local file", async () => { + const newContent = Buffer.from("NEW", "utf-8"); + mockAxiosGet(`${SKILLS_BASE_URL}/platform/foo`, newContent); + const output = uniqueDirName(); + fs.mkdirSync(absoluteOutputDir(output), { recursive: true }); + const target = path.join(absoluteOutputDir(output), "SKILL.md"); + fs.writeFileSync(target, "OLD"); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + output, + }); + + expect(fs.readFileSync(target).equals(newContent)).toBe(true); + }); + + it("Should default --output to the current working directory", async () => { + mockAxiosGet(`${SKILLS_BASE_URL}/platform/cwd-default`, skillContent); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "platform/cwd-default", + }); + + const written = path.join(process.cwd(), "SKILL.md"); + expect(fs.existsSync(written)).toBe(true); + expect(fs.readFileSync(written).equals(skillContent)).toBe(true); + }); + + it("Should URI-encode path segments while preserving slashes", async () => { + const url = `${SKILLS_BASE_URL}/asset/BOARD_V2/${encodeURIComponent("with space")}`; + mockAxiosGet(url, skillContent); + const output = uniqueDirName(); + + await new AssetRegistryService(testContext).getSkillFile({ + path: "asset/BOARD_V2/with space", + output, + }); + + const written = path.join(absoluteOutputDir(output), "SKILL.md"); + expect(fs.existsSync(written)).toBe(true); + }); + + it("Should surface a clear FatalError when the backend returns 404 for SKILL.md", async () => { + mockAxiosGetError(`${SKILLS_BASE_URL}/platform/missing`, 404, { error: "Skill not found" }); + + await expect( + new AssetRegistryService(testContext).getSkillFile({ + path: "platform/missing", + output: uniqueDirName(), + }) + ).rejects.toThrow(/Problem getting SKILL\.md for 'platform\/missing':/); + }); + + it("Should surface a clear FatalError when the backend returns 404 for a reference file", async () => { + mockAxiosGetError(`${SKILLS_BASE_URL}/platform/foo/refs/missing.md`, 404, { + error: "File not found", + }); + + await expect( + new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + file: "refs/missing.md", + output: uniqueDirName(), + }) + ).rejects.toThrow(/Problem getting skill file 'refs\/missing\.md' for 'platform\/foo':/); + }); + + it("Should throw a synchronous FatalError when --file resolves to an empty basename", async () => { + await expect( + new AssetRegistryService(testContext).getSkillFile({ + path: "platform/foo", + file: "/", + output: uniqueDirName(), + }) + ).rejects.toThrow(new FatalError("--file must point to a file, got '/'.")); + }); +}); diff --git a/tests/utls/fs-utils.ts b/tests/utls/fs-utils.ts index ac3afaf4..1903a2e1 100644 --- a/tests/utls/fs-utils.ts +++ b/tests/utls/fs-utils.ts @@ -29,10 +29,15 @@ export function writeTempFile(filename: string, contents: any): void { const fullPath = resolve(process.cwd(), filename); writeFileSync(fullPath, contents); } + +export function uniqueDirName(): string { + return uuid(); +} + export function makeTempDir(): string { - const folder = uuid(); - return makeTempDirWithName(folder); + return makeTempDirWithName(uniqueDirName()); } + export function makeTempDirWithName(folder: string): string { const fullPath = resolve(process.cwd(), folder); mkdirSync(fullPath);