Skip to content
Open
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
65 changes: 65 additions & 0 deletions docs/user-guide/asset-registry-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,71 @@ Options:
- `--assetType <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 `<name> (<path>)` followed by ` - <description>` when the skill provides one. The `<path>` 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
Comment thread
ksalihu marked this conversation as resolved.
```

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 <path>` (required) – Skill path from `asset-registry skills list` (e.g. `platform/<skill>` or `asset/<assetType>/<skill>`).
- `--file <file>` – Relative path of a reference file within the skill. Defaults to `SKILL.md` when omitted.
- `--output <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:
Expand Down
41 changes: 35 additions & 6 deletions src/commands/asset-registry/asset-registry-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -16,37 +19,63 @@ export class AssetRegistryApi {

public async listTypes(): Promise<AssetRegistryMetadata> {
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<AgentSkillsResponse> {
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<AssetRegistryDescriptor> {
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<any> {
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<any> {
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<any> {
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<Buffer> {
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("/");
}
6 changes: 6 additions & 0 deletions src/commands/asset-registry/asset-registry.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,9 @@ export interface AgentSkill {
export interface AgentSkillsResponse {
skills: AgentSkill[];
}

export interface GetSkillFileOptions {
path: string;
file?: string;
output?: string;
}
26 changes: 25 additions & 1 deletion src/commands/asset-registry/asset-registry.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,6 +33,28 @@ export class AssetRegistryService {
}
}

public async getSkillFile(opts: GetSkillFileOptions): Promise<void> {
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<void> {
const response = await this.api.listSkills();

Expand Down
15 changes: 15 additions & 0 deletions src/commands/asset-registry/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>", "Skill path from 'skills list' (e.g. platform/<skill> or asset/<assetType>/<skill>)")
.option("--file <file>", "Relative path of a reference file within the skill (defaults to SKILL.md)")
.option("--output <output>", "Destination directory (defaults to current working directory)")
.action(this.getSkillFile);
}

private async listTypes(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand Down Expand Up @@ -81,6 +88,14 @@ class Module extends IModule {
private async listSkills(context: Context, command: Command, options: OptionValues): Promise<void> {
await new AssetRegistryService(context).listSkills(!!options.json);
}

private async getSkillFile(context: Context, command: Command, options: OptionValues): Promise<void> {
await new AssetRegistryService(context).getSkillFile({
path: options.path,
file: options.file,
output: options.output,
});
}
}

export = Module;
24 changes: 18 additions & 6 deletions src/core/utils/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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
}
Expand Down Expand Up @@ -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));

Expand All @@ -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));

Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/core/utils/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function trimSlashes(value: string): string {
return value.replace(/^\/+/, "").replace(/\/+$/, "");
}
Loading
Loading