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
22 changes: 22 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ export class WriteTextFileInput extends CardDef {
@field useNonConflictingFilename = contains(BooleanField);
}

export class MigrateSkillInput extends CardDef {
// The realm to migrate: legacy `Skill` cards are read from it and the
// resulting `skills/<name>/SKILL.md` files are written back into it.
@field realm = contains(StringField);
// Overwrite an existing `SKILL.md` at the target path. When false (default),
// a skill whose target already exists is left untouched and reported as
// skipped.
@field overwrite = contains(BooleanField);
}

export class MigrateSkillResult extends CardDef {
// Absolute URLs of the `SKILL.md` files written by this run.
@field migratedFiles = containsMany(StringField);
// Ids of legacy `Skill` cards skipped because their target `SKILL.md`
// already exists and `overwrite` was not set.
@field skippedSkillIds = containsMany(StringField);
// Ids of skills skipped because they had no instructions to write — e.g. a
// markdown-backed skill whose linked instructions did not resolve. Reported
// rather than written out as an empty `SKILL.md`.
@field emptySkillIds = containsMany(StringField);
}

export class WriteBinaryFileInput extends CardDef {
@field path = contains(StringField);
@field realm = contains(StringField);
Expand Down
6 changes: 6 additions & 0 deletions packages/host/app/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import * as InvalidateRealmIdentifiersCommandModule from './invalidate-realm-ide
import * as InviteUserToRoomCommandModule from './invite-user-to-room';
import * as LintAndFixCommandModule from './lint-and-fix';
import * as ListingBuildCommandModule from './listing-action-build';
import * as MigrateSkillCommandModule from './migrate-skill';
import * as OneShotLlmRequestCommandModule from './one-shot-llm-request';
import * as OpenAiAssistantRoomCommandModule from './open-ai-assistant-room';
import * as OpenCreateListingModalCommandModule from './open-create-listing-modal';
Expand Down Expand Up @@ -209,6 +210,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) {
'@cardstack/boxel-host/commands/listing-action-build',
ListingBuildCommandModule,
);
virtualNetwork.shimModule(
'@cardstack/boxel-host/commands/migrate-skill',
MigrateSkillCommandModule,
);
virtualNetwork.shimModule(
'@cardstack/boxel-host/commands/create-listing-pr-request',
CreateListingPRRequestCommandModule,
Expand Down Expand Up @@ -567,6 +572,7 @@ export const HostCommandClasses: (typeof HostBaseCommand<any, any>)[] = [
UpdateRoomSkillsCommandModule.default,
UseAiAssistantCommandModule.default,
ValidateRealmCommandModule.default,
MigrateSkillCommandModule.default,
WriteBinaryFileCommandModule.default,
WriteTextFileCommandModule.default,
];
194 changes: 194 additions & 0 deletions packages/host/app/commands/migrate-skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { service } from '@ember/service';

import { stringify as stringifyYaml } from 'yaml';

import { rri, skillCardRef } from '@cardstack/runtime-common';

import type * as BaseCommandModule from 'https://cardstack.com/base/command';
import type { Skill } from 'https://cardstack.com/base/skill';

import HostBaseCommand from '../lib/host-base-command';

import type CardService from '../services/card-service';
import type RealmService from '../services/realm';
import type StoreService from '../services/store';

// A single command in the migrated frontmatter — the same shape
// `SkillFrontmatterField.commands` (a `containsMany(CommandField)`) parses back
// out of `boxel.commands`.
interface FrontmatterCommand {
codeRef: { module: string; name: string };
// Always emitted explicitly. The host auto-executes a command only when
// `requiresApproval === false` (`command-auto-execute.ts`) and otherwise
// treats a missing value as `true` (`message-builder.ts`), so dropping an
// explicit `false` would silently flip an auto-executing command back to
// approval-required. Preserve the source value, defaulting a missing one to
// `true` to match that downstream behavior.
requiresApproval: boolean;
}

// Convert a skill name into a directory-safe slug for `skills/<slug>/SKILL.md`.
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}

// Last path segment of a card id, minus its extension — a stable slug fallback
// when a skill has no usable name.
function basenameSlug(id: string): string {
let pathname: string;
try {
pathname = new URL(id).pathname;
} catch {
pathname = id;
}
let name = pathname.split('/').pop() ?? '';
return slugify(name.replace(/\.[^/.]+$/, ''));
}

export default class MigrateSkillCommand extends HostBaseCommand<
typeof BaseCommandModule.MigrateSkillInput,
typeof BaseCommandModule.MigrateSkillResult
> {
@service declare private cardService: CardService;
@service declare private realm: RealmService;
@service declare private store: StoreService;

description = `Migrate a realm's legacy Skill cards into skills/<name>/SKILL.md \
files with boxel.kind: skill frontmatter.`;
static actionVerb = 'Migrate';

async getInputType() {
let commandModule = await this.loadCommandModule();
const { MigrateSkillInput } = commandModule;
return MigrateSkillInput;
}

requireInputFields = ['realm'];

protected async run(
input: BaseCommandModule.MigrateSkillInput,
): Promise<BaseCommandModule.MigrateSkillResult> {
let realmUrl = this.realm.realmOf(rri(input.realm));
if (!realmUrl) {
throw new Error(`Invalid or unknown realm provided: ${input.realm}`);
}

// The `type` filter matches the legacy `Skill` card and its subclasses
// (e.g. `SkillPlus`, `SkillPlusMarkdown`), so every flavour of legacy skill
// in the realm is migrated.
let skills = await this.store.search<Skill>(
{ filter: { type: skillCardRef } },
[realmUrl],
);

// Sort by id so slug de-duplication is deterministic: re-running the command
// assigns the same `-2`/`-3` suffixes in the same order, which keeps the
// skip-if-exists check stable instead of producing fresh duplicates.
skills.sort((a, b) => (a.id ?? '').localeCompare(b.id ?? ''));

let migratedFiles: string[] = [];
let skippedSkillIds: string[] = [];
let emptySkillIds: string[] = [];
let usedSlugs = new Set<string>();

for (let skill of skills) {
// Skip — and report — skills with nothing to transcribe rather than
// writing an empty `SKILL.md`. This guards the markdown-backed subclasses
// (e.g. `SkillPlusMarkdown`), whose `instructions` is computed from a
// linked file that may not have resolved in the search result.
let body = (skill.instructions ?? '').trim();
if (!body) {
if (skill.id) {
emptySkillIds.push(skill.id);
}
continue;
}

let slug = this.slugForSkill(skill, usedSlugs);
usedSlugs.add(slug);

let url = new URL(`skills/${slug}/SKILL.md`, realmUrl);

if (!input.overwrite && (await this.fileExists(url))) {
if (skill.id) {
skippedSkillIds.push(skill.id);
}
continue;
}

let content = this.buildSkillMarkdown(skill, body);
await this.cardService.saveSource(
url,
content,
input.overwrite ? 'editor' : 'create-file',
);
migratedFiles.push(url.href);
}

let commandModule = await this.loadCommandModule();
const { MigrateSkillResult } = commandModule;
return new MigrateSkillResult({
migratedFiles,
skippedSkillIds,
emptySkillIds,
});
}

private slugForSkill(skill: Skill, usedSlugs: Set<string>): string {
let name = skill.cardTitle ?? '';
let base = slugify(name) || basenameSlug(skill.id ?? '') || 'skill';
let slug = base;
let suffix = 2;
while (usedSlugs.has(slug)) {
slug = `${base}-${suffix++}`;
}
return slug;
}

private buildSkillMarkdown(skill: Skill, body: string): string {
let commands = (skill.commands ?? []).reduce<FrontmatterCommand[]>(
(acc, command) => {
let module = command.codeRef?.module;
let name = command.codeRef?.name;
if (module && name) {
acc.push({
codeRef: { module, name },
requiresApproval: command.requiresApproval ?? true,
});
}
return acc;
},
[],
);

// Shared top-level keys (read byte-for-byte by Claude Code) first, then the
// Boxel-only `boxel:` namespace that carries `kind` and `commands`.
let frontmatter: Record<string, unknown> = {
name: skill.cardTitle ?? '',
description: skill.cardDescription ?? '',
boxel: {
kind: 'skill',
...(commands.length > 0 ? { commands } : {}),
},
};

return `---\n${stringifyYaml(frontmatter)}---\n\n${body}\n`;
}

private async fileExists(url: URL): Promise<boolean> {
let { status } = await this.cardService.getSource(url);
if (status === 404) {
return false;
}
if (status === 200 || status === 406) {
return true;
}
throw new Error(`Error checking if file exists at ${url}: ${status}`);
}
}
Loading
Loading